cc-viewer 1.6.348 → 1.7.1
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/README.md +13 -1
- package/cli.js +116 -37
- package/dist/assets/App-gUSmU6Ny.js +2 -0
- package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-DAR39A3S.js} +1 -1
- package/dist/assets/Mobile-qfRsRth0.js +1 -0
- package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
- package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
- package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
- package/dist/assets/clone-BQtvy9Ae.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
- package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
- package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
- package/dist/assets/index-B51Ci0GL.js +2 -0
- package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
- package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
- package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
- package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
- package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
- package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
- package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
- package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
- package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
- package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
- package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-DFrHmgnK.css} +1 -1
- package/dist/assets/seqResourceLoaders-DZJxMMaR.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
- package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
- package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
- package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
- package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +4 -3
- package/package.json +3 -1
- package/server/i18n.js +21 -141
- package/server/interceptor.js +211 -403
- package/server/lib/async-write-queue.js +12 -1
- package/server/lib/delta-reconstructor.js +3 -3
- package/server/lib/error-report.js +45 -0
- package/server/lib/im-log-watcher.js +68 -8
- package/server/lib/interceptor-core.js +19 -174
- package/server/lib/log-management.js +214 -247
- package/server/lib/log-stream.js +59 -108
- package/server/lib/log-watcher.js +62 -274
- package/server/lib/log-zip.js +148 -0
- package/server/lib/session-boundary.js +266 -0
- package/server/lib/session-pin-store.js +10 -19
- package/server/lib/stats-worker.js +138 -267
- package/server/lib/user-prompt-extract.js +250 -0
- package/server/lib/v2/adapter.js +1124 -0
- package/server/lib/v2/blob-store.js +44 -0
- package/server/lib/v2/conversation-store.js +217 -0
- package/server/lib/v2/convert-manager.js +79 -0
- package/server/lib/v2/convert-worker.js +42 -0
- package/server/lib/v2/convert.js +409 -0
- package/server/lib/v2/identity.js +204 -0
- package/server/lib/v2/journal.js +62 -0
- package/server/lib/v2/layout.js +167 -0
- package/server/lib/v2/live-feed.js +558 -0
- package/server/lib/v2/meta-rows.js +360 -0
- package/server/lib/v2/migrate-prompt.js +54 -0
- package/server/lib/v2/replay.js +191 -0
- package/server/lib/v2/session-select.js +222 -0
- package/server/lib/v2/singleflight.js +104 -0
- package/server/lib/v2/v2-writer.js +528 -0
- package/server/lib/v2/verify.js +233 -0
- package/server/lib/wire-compress.js +167 -0
- package/server/routes/events.js +120 -150
- package/server/routes/files-fs.js +4 -2
- package/server/routes/im.js +11 -2
- package/server/routes/logs.js +209 -164
- package/server/routes/preferences.js +0 -3
- package/server/routes/project-meta.js +2 -3
- package/server/routes/session-pin.js +7 -8
- package/server/routes/v2.js +60 -0
- package/server/routes/workspaces.js +50 -14
- package/server/server.js +70 -66
- package/server/workspace-registry.js +24 -4
- package/src/utils/apiUrl.js +26 -0
- package/src/utils/askFallback.js +21 -0
- package/src/utils/askOptionDesc.js +55 -0
- package/src/utils/askPortalMatcher.js +54 -0
- package/src/utils/autoApproveOptions.js +27 -0
- package/src/utils/avatarAnimationPostPass.js +49 -0
- package/src/utils/builtinPresets.js +18 -0
- package/src/utils/clearCheckpoint.js +7 -0
- package/src/utils/commandValidator.js +45 -0
- package/src/utils/contentFilter.js +620 -0
- package/src/utils/contextRaw.js +18 -0
- package/src/utils/contextSidebarNavigation.js +16 -0
- package/src/utils/contextTurns.js +117 -0
- package/src/utils/displayScaleHelper.js +47 -0
- package/src/utils/effectiveModel.js +13 -0
- package/src/utils/entry-slim.js +563 -0
- package/src/utils/entryCache.js +144 -0
- package/src/utils/errorReport.js +6 -0
- package/src/utils/fileExpandedPathsStorage.js +70 -0
- package/src/utils/fileIcons.jsx +36 -0
- package/src/utils/fileOpen.js +37 -0
- package/src/utils/formatters.js +48 -0
- package/src/utils/gitApi.js +50 -0
- package/src/utils/gitTreeBuilder.js +19 -0
- package/src/utils/helpers.js +846 -0
- package/src/utils/identityHeal.js +99 -0
- package/src/utils/imConnState.js +59 -0
- package/src/utils/imOrigin.js +24 -0
- package/src/utils/imTr.js +7 -0
- package/src/utils/imageCompress.js +83 -0
- package/src/utils/imageDownscale.js +28 -0
- package/src/utils/imageResize.js +112 -0
- package/src/utils/ingestPipeline.js +47 -0
- package/src/utils/lazyWithReload.js +53 -0
- package/src/utils/markdown.js +30 -0
- package/src/utils/markdownIncremental.js +39 -0
- package/src/utils/markdownProfiler.js +113 -0
- package/src/utils/mdExtensionDetect.js +64 -0
- package/src/utils/memoryLinkParser.js +44 -0
- package/src/utils/modalMask.js +15 -0
- package/src/utils/monotime.js +6 -0
- package/src/utils/pinnedMenu.js +55 -0
- package/src/utils/presetShortcuts.js +14 -0
- package/src/utils/projectAlias.js +135 -0
- package/src/utils/promptClassifier.js +112 -0
- package/src/utils/promptDetect.js +196 -0
- package/src/utils/promptNav.js +60 -0
- package/src/utils/ptyChunkBuilder.js +251 -0
- package/src/utils/quickMenuHoverIntent.js +35 -0
- package/src/utils/rateLimitParser.js +119 -0
- package/src/utils/readResultPool.js +106 -0
- package/src/utils/refreshCachedItemProp.js +30 -0
- package/src/utils/requestType.js +208 -0
- package/src/utils/resizeCalc.js +21 -0
- package/src/utils/resolveLocalized.js +17 -0
- package/src/utils/searchApi.js +47 -0
- package/src/utils/searchReplace.js +51 -0
- package/src/utils/seqResourceLoaders.js +79 -0
- package/src/utils/sessionManager.js +471 -0
- package/src/utils/sessionMerge.js +180 -0
- package/src/utils/skillModalController.js +111 -0
- package/src/utils/skillsParser.js +116 -0
- package/src/utils/slashCommandLabels.js +80 -0
- package/src/utils/splitDragCalc.js +73 -0
- package/src/utils/stickyBottomController.js +643 -0
- package/src/utils/svgSanitize.js +48 -0
- package/src/utils/systemTags.js +47 -0
- package/src/utils/tClaude.js +20 -0
- package/src/utils/teamModalBuilder.js +351 -0
- package/src/utils/teamSessionParser.js +190 -0
- package/src/utils/teammateAvatars.js +194 -0
- package/src/utils/teammateDetector.js +121 -0
- package/src/utils/termDiag.js +114 -0
- package/src/utils/terminalClipboard.js +117 -0
- package/src/utils/terminalWriteQueue.js +398 -0
- package/src/utils/thinkingMerge.js +49 -0
- package/src/utils/toolCatalog.js +29 -0
- package/src/utils/toolResultBuilder.js +393 -0
- package/src/utils/toolResultClassifier.js +16 -0
- package/src/utils/toolResultCore.js +167 -0
- package/src/utils/toolsDiff.js +44 -0
- package/src/utils/toolsXmlFormatter.js +3 -0
- package/src/utils/ultraplanController.js +109 -0
- package/src/utils/ultraplanExperts.js +86 -0
- package/src/utils/ultraplanTemplates.js +154 -0
- package/src/utils/userImageRefs.js +49 -0
- package/src/utils/v3Assembler.js +112 -0
- package/src/utils/v3Rows.js +48 -0
- package/src/utils/voicePackPlayer.js +233 -0
- package/src/utils/webSearchGrouping.js +111 -0
- package/src/utils/workflowFormat.js +52 -0
- package/src/utils/workflowRuns.js +118 -0
- package/src/utils/workflowStore.js +94 -0
- package/dist/assets/App-VTo4NVdo.js +0 -2
- package/dist/assets/Mobile-CcmYvMm_.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
- package/dist/assets/clone-md-Mc1-T.js +0 -1
- package/dist/assets/index-DCo650PG.js +0 -2
- package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
- package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
- package/server/lib/instance-registry.js +0 -57
- package/server/lib/jsonl-archive.js +0 -180
- package/server/lib/teammate-detect.js +0 -32
- package/server/lib/zip-safety.js +0 -68
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire v3 (V3.S2) — request-list metadata rows.
|
|
3
|
+
*
|
|
4
|
+
* A row is a journal fold (req + done by seq) plus two body-derived fields
|
|
5
|
+
* (typeTag, cacheLoss) computed in a bounded streaming Pass B over exactly the
|
|
6
|
+
* windowed members: bodies are materialized one at a time, classified, and
|
|
7
|
+
* dropped — the wire carries rows only (~1.3KB each vs full entries).
|
|
8
|
+
*
|
|
9
|
+
* Two DELIBERATE classification divergences from the legacy full-entry list
|
|
10
|
+
* (both more correct; pinned in test/v2-meta-rows.test.js, declared in
|
|
11
|
+
* history.md):
|
|
12
|
+
* - membership is the journal fold — a SUPERSET of the synthesizer's output
|
|
13
|
+
* (conv-gapped crash-orphans get a row; their detail fetch answers 404 and
|
|
14
|
+
* typeTag stays journal-derived);
|
|
15
|
+
* - mainAgent is `kind==='main' && !meta.leader` — the isMain semantics the
|
|
16
|
+
* mainAgentRing already pinned, dropping "main-looking" sub/misc bodies.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
|
+
import { join, basename } from 'node:path';
|
|
20
|
+
import { readJsonlTolerant } from './replay.js';
|
|
21
|
+
import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
|
|
22
|
+
import { SingleFlight } from './singleflight.js';
|
|
23
|
+
import { reportSwallowed } from '../error-report.js';
|
|
24
|
+
import { classifyRequest } from '../../../src/utils/requestType.js';
|
|
25
|
+
|
|
26
|
+
// KEEP IN SYNC: server/lib/v2/adapter.js itemKey — (sessionId, seq) identity.
|
|
27
|
+
const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
|
|
28
|
+
|
|
29
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/** KEEP IN SYNC: src/utils/helpers.js stripPrivateKeys. */
|
|
32
|
+
function stripPrivateKeys(obj) {
|
|
33
|
+
if (Array.isArray(obj)) return obj.map(stripPrivateKeys);
|
|
34
|
+
if (obj && typeof obj === 'object') {
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const key of Object.keys(obj)) {
|
|
37
|
+
if (key.startsWith('_')) continue;
|
|
38
|
+
result[key] = stripPrivateKeys(obj[key]);
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
return obj;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* KEEP IN SYNC: src/utils/helpers.js _computeCacheLoss (that module cannot be
|
|
47
|
+
* imported server-side — SVG/i18n dependency chain). One deliberate fix: the
|
|
48
|
+
* client computes the ttl gap as `stringTs - stringTs` (NaN, so its ttl branch
|
|
49
|
+
* never fires); here the timestamps are parsed — pinned in the unit test.
|
|
50
|
+
*/
|
|
51
|
+
export function computeCacheLoss(prevEntry, currEntry) {
|
|
52
|
+
const gap = Date.parse(currEntry.timestamp) - Date.parse(prevEntry.timestamp);
|
|
53
|
+
if (gap > CACHE_TTL_MS) return { reason: 'ttl', reasons: ['ttl'] };
|
|
54
|
+
const prev = stripPrivateKeys(prevEntry.body);
|
|
55
|
+
const curr = stripPrivateKeys(currEntry.body);
|
|
56
|
+
if (!prev || !curr) return { reason: 'key_change', reasons: ['key_change'] };
|
|
57
|
+
const reasons = [];
|
|
58
|
+
if (prev.model !== curr.model) reasons.push('model_change');
|
|
59
|
+
if (JSON.stringify(prev.system) !== JSON.stringify(curr.system)) reasons.push('system_change');
|
|
60
|
+
if (JSON.stringify(prev.tools) !== JSON.stringify(curr.tools)) reasons.push('tools_change');
|
|
61
|
+
const prevMsgs = prev.messages || [];
|
|
62
|
+
const currMsgs = curr.messages || [];
|
|
63
|
+
if (currMsgs.length < prevMsgs.length) {
|
|
64
|
+
reasons.push('msg_truncated');
|
|
65
|
+
} else {
|
|
66
|
+
const prefixLen = Math.min(prevMsgs.length, currMsgs.length);
|
|
67
|
+
let prefixMatch = true;
|
|
68
|
+
for (let j = 0; j < prefixLen; j++) {
|
|
69
|
+
if (JSON.stringify(prevMsgs[j]) !== JSON.stringify(currMsgs[j])) { prefixMatch = false; break; }
|
|
70
|
+
}
|
|
71
|
+
if (!prefixMatch) reasons.push('msg_modified');
|
|
72
|
+
}
|
|
73
|
+
if (reasons.length === 0) reasons.push('key_change');
|
|
74
|
+
return { reason: reasons[0], reasons };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readMeta(dir) {
|
|
78
|
+
try { return JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { return null; }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Fold one session dir's journal into row skeletons (no bodies touched). */
|
|
82
|
+
function foldDir(dir) {
|
|
83
|
+
const meta = readMeta(dir);
|
|
84
|
+
const isTeammateDir = !!(meta && meta.leader);
|
|
85
|
+
const sessionId = (meta && meta.sessionId) || basename(dir);
|
|
86
|
+
const reqs = new Map();
|
|
87
|
+
const dones = new Map();
|
|
88
|
+
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
89
|
+
if (line.ph === 'req' && !reqs.has(line.seq)) reqs.set(line.seq, line);
|
|
90
|
+
else if (line.ph === 'done' && !dones.has(line.seq)) dones.set(line.seq, line);
|
|
91
|
+
}
|
|
92
|
+
const rows = [];
|
|
93
|
+
for (const [seq, r] of reqs) {
|
|
94
|
+
const d = dones.get(seq);
|
|
95
|
+
const usage = d && d.usage ? {
|
|
96
|
+
input_tokens: d.usage.in || 0,
|
|
97
|
+
output_tokens: d.usage.out || 0,
|
|
98
|
+
cache_read_input_tokens: d.usage.cr || 0,
|
|
99
|
+
cache_creation_input_tokens: d.usage.cw || 0,
|
|
100
|
+
} : null;
|
|
101
|
+
rows.push({
|
|
102
|
+
seq,
|
|
103
|
+
sessionId,
|
|
104
|
+
timestamp: r.ts || '',
|
|
105
|
+
url: r.url || '',
|
|
106
|
+
method: r.method || 'POST',
|
|
107
|
+
// V3.S5 assembler inputs: which conv channel this request's messages
|
|
108
|
+
// live in, and whether its event was a snapshot (checkpoint marker).
|
|
109
|
+
conv: r.conv,
|
|
110
|
+
evt: r.evt,
|
|
111
|
+
kind: r.kind || 'misc',
|
|
112
|
+
mainAgent: r.kind === 'main' && !isTeammateDir,
|
|
113
|
+
// v1 entry contract (adapter.js:427): teammate = agentName string | true
|
|
114
|
+
teammate: isTeammateDir ? ((meta.leader && meta.leader.agentName) || true) : undefined,
|
|
115
|
+
model: r.model,
|
|
116
|
+
proxyUrl: r.proxy && r.proxy.url ? r.proxy.url : undefined,
|
|
117
|
+
status: d && typeof d.http === 'number' ? d.http : undefined,
|
|
118
|
+
duration: d && typeof d.dur === 'number' ? d.dur : undefined,
|
|
119
|
+
usage,
|
|
120
|
+
inProgress: !d,
|
|
121
|
+
typeTag: null, // Pass B
|
|
122
|
+
cacheLoss: null, // Pass B
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return rows;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read the request-list metadata rows for a session (leader + folded
|
|
130
|
+
* teammates), windowed like the entry list: dedup timestamp|url last-wins →
|
|
131
|
+
* `before` filter → tail-`limit`. Pass B streams the window members' bodies
|
|
132
|
+
* once to attach typeTag/cacheLoss.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} sessionDir
|
|
135
|
+
* @param {{limit?: number, before?: string|null, since?: string|null, passB?: boolean}} [opts] -
|
|
136
|
+
* passB:false skips body classification (live increments send rows first
|
|
137
|
+
* and re-send corrections once Pass B / the next request lands);
|
|
138
|
+
* `since` (review P1-2) scopes an incremental reconnect to the delta window
|
|
139
|
+
* — without it every mobile reconnect re-transmits the full session
|
|
140
|
+
* @returns {Promise<{rows: object[], totalCount: number, hasMore: boolean, oldestTimestamp: string}>}
|
|
141
|
+
*/
|
|
142
|
+
export async function readV2RequestsMeta(sessionDir, opts = {}) {
|
|
143
|
+
const limit = opts.limit || 0;
|
|
144
|
+
const before = opts.before || null;
|
|
145
|
+
const since = opts.since || null;
|
|
146
|
+
const meta = readMeta(sessionDir);
|
|
147
|
+
const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
|
|
148
|
+
const dirs = [sessionDir];
|
|
149
|
+
try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
|
|
150
|
+
|
|
151
|
+
// k-way ordering parity: same (ts, sessionId, seq) comparator as iterateV2Items
|
|
152
|
+
const all = dirs.flatMap(foldDir);
|
|
153
|
+
all.sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : a.seq - b.seq));
|
|
154
|
+
|
|
155
|
+
// dedup timestamp|url last-wins (nokey rows key on seq — stable, unlike array index)
|
|
156
|
+
const dedup = new Map();
|
|
157
|
+
for (const row of all) {
|
|
158
|
+
const key = row.timestamp && row.url ? `${row.timestamp}|${row.url}` : `__nokey_${row.sessionId}_${row.seq}`;
|
|
159
|
+
dedup.set(key, row);
|
|
160
|
+
}
|
|
161
|
+
let rows = [...dedup.values()];
|
|
162
|
+
if (before) rows = rows.filter((r) => r.timestamp && r.timestamp < before);
|
|
163
|
+
const totalCount = rows.length;
|
|
164
|
+
let hasMore = false;
|
|
165
|
+
if (limit > 0 && rows.length > limit) {
|
|
166
|
+
hasMore = true;
|
|
167
|
+
rows = rows.slice(rows.length - limit);
|
|
168
|
+
}
|
|
169
|
+
const oldestTimestamp = rows.length > 0 ? rows[0].timestamp : '';
|
|
170
|
+
// Emission-time since filter (mirrors selectV2Window: applied after the
|
|
171
|
+
// window selection; nokey rows always resend — the legacy contract).
|
|
172
|
+
if (since) rows = rows.filter((r) => !r.timestamp || r.timestamp >= since);
|
|
173
|
+
|
|
174
|
+
if (opts.passB !== false && rows.length > 0) {
|
|
175
|
+
await attachBodyFields(sessionDir, rows);
|
|
176
|
+
}
|
|
177
|
+
return { rows, totalCount, hasMore, oldestTimestamp };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// In-flight coalescing for the v3 cold reads (review P2-b): a reconnect storm
|
|
181
|
+
// is N tabs hitting the same sessionDir at once — without this each ran its
|
|
182
|
+
// own full journal fold + Pass B + conv-file reads. cached:false — /events is
|
|
183
|
+
// a live-attach caller (S10b F5b: a stale TTL window would widen the
|
|
184
|
+
// cold-load→attach broadcast-loss gap), so callers only JOIN in-flight runs.
|
|
185
|
+
const _coldBundleFlight = new SingleFlight({ ttlMs: 500 });
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The complete v3 cold read for one /events connection: windowed rows +
|
|
189
|
+
* native conv/responses payloads, single-flighted per (dir, window).
|
|
190
|
+
*/
|
|
191
|
+
export function readV2ColdBundle(sessionDir, { limit = 0, since = null } = {}) {
|
|
192
|
+
const key = `bundle:${sessionDir}|${limit}|${since || ''}`;
|
|
193
|
+
return _coldBundleFlight.run(key, async () => {
|
|
194
|
+
const meta = await readV2RequestsMeta(sessionDir, { limit, since });
|
|
195
|
+
const native = await readV2NativeCold(sessionDir, meta.rows);
|
|
196
|
+
return { meta, native };
|
|
197
|
+
}, { cached: false });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Wire v3 (V3.S4) cold native lines for the flagged client's assembler:
|
|
202
|
+
* per conv channel, raw lines from the LAST snapshot at-or-before the
|
|
203
|
+
* window's oldest seq (per session) through the end; responses lines for
|
|
204
|
+
* exactly the window member seqs. Lines are forwarded verbatim (string
|
|
205
|
+
* concat, no parse/stringify round trip beyond seq/t sniffing).
|
|
206
|
+
*
|
|
207
|
+
* ASYNC with event-loop yields (review P1-4): a large session's conv files
|
|
208
|
+
* total tens of MB — reading and snapshot-scanning them in one synchronous
|
|
209
|
+
* stretch blocks every connected client for the duration of the cold load.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} sessionDir - leader dir (teammate dirs folded like the rows)
|
|
212
|
+
* @param {object[]} rows - the window rows (readV2RequestsMeta output)
|
|
213
|
+
* @returns {Promise<{convPayloads: string[], respPayloads: string[]}>}
|
|
214
|
+
* pre-serialized JSON payloads for `v3_conv` / `v3_resp` SSE frames
|
|
215
|
+
*/
|
|
216
|
+
export async function readV2NativeCold(sessionDir, rows) {
|
|
217
|
+
const yieldLoop = () => new Promise((resolve) => setImmediate(resolve));
|
|
218
|
+
const minSeqBySession = new Map();
|
|
219
|
+
const memberSeqsBySession = new Map();
|
|
220
|
+
for (const r of rows) {
|
|
221
|
+
const cur = minSeqBySession.get(r.sessionId);
|
|
222
|
+
if (cur === undefined || r.seq < cur) minSeqBySession.set(r.sessionId, r.seq);
|
|
223
|
+
let set = memberSeqsBySession.get(r.sessionId);
|
|
224
|
+
if (!set) { set = new Set(); memberSeqsBySession.set(r.sessionId, set); }
|
|
225
|
+
set.add(r.seq);
|
|
226
|
+
}
|
|
227
|
+
const meta = readMeta(sessionDir);
|
|
228
|
+
const leaderUuid = (meta && meta.sessionId) || basename(sessionDir);
|
|
229
|
+
const dirs = [sessionDir];
|
|
230
|
+
try { dirs.push(...findTeammateSessionDirs(sessionDir, leaderUuid).map((t) => t.dir)); } catch { /* leaderless */ } // items are {dir, leader}
|
|
231
|
+
|
|
232
|
+
const convPayloads = [];
|
|
233
|
+
const respPayloads = [];
|
|
234
|
+
for (const dir of dirs) {
|
|
235
|
+
const dirMeta = readMeta(dir);
|
|
236
|
+
const sessionId = (dirMeta && dirMeta.sessionId) || basename(dir);
|
|
237
|
+
const minSeq = minSeqBySession.get(sessionId);
|
|
238
|
+
const memberSeqs = memberSeqsBySession.get(sessionId);
|
|
239
|
+
if (minSeq === undefined) continue; // no window rows from this session
|
|
240
|
+
// conv channels: raw lines from the channel's last snapshot ≤ minSeq
|
|
241
|
+
const convRoot = join(dir, 'conversations');
|
|
242
|
+
let keys = [];
|
|
243
|
+
try { keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { /* no conv dir */ }
|
|
244
|
+
for (const key of keys) {
|
|
245
|
+
let files = [];
|
|
246
|
+
try { files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); } catch { continue; }
|
|
247
|
+
const raws = [];
|
|
248
|
+
for (const f of files) {
|
|
249
|
+
let text = '';
|
|
250
|
+
try { text = readFileSync(join(convRoot, key, f), 'utf-8'); } catch { continue; }
|
|
251
|
+
for (const line of text.split('\n')) {
|
|
252
|
+
const trimmed = line.trim();
|
|
253
|
+
if (trimmed) raws.push(trimmed);
|
|
254
|
+
}
|
|
255
|
+
await yieldLoop(); // one yield per conv file read
|
|
256
|
+
}
|
|
257
|
+
// last snapshot at-or-before the window start for this session
|
|
258
|
+
let start = 0;
|
|
259
|
+
for (let i = 0; i < raws.length; i++) {
|
|
260
|
+
try {
|
|
261
|
+
const ev = JSON.parse(raws[i]);
|
|
262
|
+
if (ev.t === 'snapshot' && ev.seq <= minSeq) start = i;
|
|
263
|
+
if (ev.seq > minSeq) break;
|
|
264
|
+
} catch { /* tolerate torn line */ }
|
|
265
|
+
if (i % 2000 === 1999) await yieldLoop();
|
|
266
|
+
}
|
|
267
|
+
const windowRaws = raws.slice(start);
|
|
268
|
+
// Cap each frame at ~512KB of lines: a multi-MB single SSE event forces
|
|
269
|
+
// one giant synchronous JSON.parse on the client's main thread (no
|
|
270
|
+
// paint, no rAF — the loading UI freezes). Small frames are macrotask
|
|
271
|
+
// boundaries: the browser paints and the byte meter ticks between them.
|
|
272
|
+
pushChunked(convPayloads, windowRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"channel":${JSON.stringify(key)},"lines":[${linesJson}]}`);
|
|
273
|
+
}
|
|
274
|
+
// responses: exactly the window member seqs
|
|
275
|
+
let respText = '';
|
|
276
|
+
try { respText = readFileSync(join(dir, 'responses.jsonl'), 'utf-8'); } catch { /* none yet */ }
|
|
277
|
+
await yieldLoop();
|
|
278
|
+
const respRaws = [];
|
|
279
|
+
for (const line of respText.split('\n')) {
|
|
280
|
+
const trimmed = line.trim();
|
|
281
|
+
if (!trimmed) continue;
|
|
282
|
+
const m = /"seq":\s*(\d+)/.exec(trimmed);
|
|
283
|
+
if (m && memberSeqs && memberSeqs.has(Number(m[1]))) respRaws.push(trimmed);
|
|
284
|
+
}
|
|
285
|
+
pushChunked(respPayloads, respRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"lines":[${linesJson}]}`);
|
|
286
|
+
}
|
|
287
|
+
return { convPayloads, respPayloads };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const NATIVE_FRAME_BUDGET = 512 * 1024;
|
|
291
|
+
|
|
292
|
+
/** Split raw lines into ≤~512KB payloads (see comment at the conv call site). */
|
|
293
|
+
function pushChunked(out, raws, wrap) {
|
|
294
|
+
let buf = [];
|
|
295
|
+
let bytes = 0;
|
|
296
|
+
const flush = () => {
|
|
297
|
+
if (buf.length > 0) out.push(wrap(buf.join(',')));
|
|
298
|
+
buf = [];
|
|
299
|
+
bytes = 0;
|
|
300
|
+
};
|
|
301
|
+
for (const raw of raws) {
|
|
302
|
+
buf.push(raw);
|
|
303
|
+
bytes += raw.length + 1;
|
|
304
|
+
if (bytes >= NATIVE_FRAME_BUDGET) flush();
|
|
305
|
+
}
|
|
306
|
+
flush();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Bounded streaming Pass B: materialize exactly the rows' bodies (one at a
|
|
311
|
+
* time), classify typeTag with one-entry lookahead (classifyRequest's
|
|
312
|
+
* Preflight/Plan detection reads nextReq), compute cacheLoss against the
|
|
313
|
+
* retained previous mainAgent body, then drop the body.
|
|
314
|
+
*/
|
|
315
|
+
async function attachBodyFields(sessionDir, rows) {
|
|
316
|
+
const rowByKey = new Map(rows.map((r) => [itemKey(r.sessionId, r.seq), r]));
|
|
317
|
+
const materialize = (sessionId, seq) => rowByKey.has(itemKey(sessionId, seq));
|
|
318
|
+
let pending = null; // { row, entry } awaiting its nextReq for classify
|
|
319
|
+
let prevMainFull = null; // previous mainAgent full-body entry (cacheLoss)
|
|
320
|
+
let n = 0;
|
|
321
|
+
const finish = (slot, nextEntry) => {
|
|
322
|
+
const { row, entry } = slot;
|
|
323
|
+
try {
|
|
324
|
+
const tag = classifyRequest(entry, nextEntry);
|
|
325
|
+
row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
// Diagnostic-worthy (a throw silently mis-tags the row; the live path
|
|
328
|
+
// reports the same failure) — CLAUDE.md swallow rule.
|
|
329
|
+
reportSwallowed('v2-meta.row-classify', err);
|
|
330
|
+
row.typeTag = null; // journal-derived kind still renders
|
|
331
|
+
}
|
|
332
|
+
// authoritative synthesis-level markers (parity with the legacy list)
|
|
333
|
+
row.mainAgent = entry.mainAgent === true;
|
|
334
|
+
if (entry.teammate) row.teammate = entry.teammate;
|
|
335
|
+
if (entry.body && entry.body.model) row.model = entry.body.model;
|
|
336
|
+
if (row.mainAgent) {
|
|
337
|
+
const usage = entry.response?.body?.usage;
|
|
338
|
+
const cacheCreate = usage?.cache_creation_input_tokens || 0;
|
|
339
|
+
const cacheRead = usage?.cache_read_input_tokens || 0;
|
|
340
|
+
if (prevMainFull && cacheCreate > 0 && cacheCreate > cacheRead) {
|
|
341
|
+
try { row.cacheLoss = computeCacheLoss(prevMainFull, entry); } catch (err) { reportSwallowed('v2-meta.row-cacheloss', err); }
|
|
342
|
+
}
|
|
343
|
+
prevMainFull = entry;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
for (const item of iterateV2Items(sessionDir, { materialize })) {
|
|
347
|
+
const row = rowByKey.get(itemKey(item.sessionId, item.seq));
|
|
348
|
+
if (!row) continue;
|
|
349
|
+
// main deltas carry only their appended slice — classification and
|
|
350
|
+
// cacheLoss need the full replayed state (stateRef aliases it transiently)
|
|
351
|
+
let entry = item.entry;
|
|
352
|
+
if (row.mainAgent && item.stateRef && entry.body) {
|
|
353
|
+
entry = { ...entry, body: { ...entry.body, messages: item.stateRef } };
|
|
354
|
+
}
|
|
355
|
+
if (pending) finish(pending, entry);
|
|
356
|
+
pending = { row, entry };
|
|
357
|
+
if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
|
|
358
|
+
}
|
|
359
|
+
if (pending) finish(pending, null);
|
|
360
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// 1.7.0 migration prompt (plan P2): detect legacy v1 logs that have not been
|
|
2
|
+
// converted to the v2 session store yet, so the UI can offer one-click
|
|
3
|
+
// migration at startup / workspace switch. Detection is pure filesystem
|
|
4
|
+
// arithmetic on top of the converter's own bookkeeping — a v1 file counts as
|
|
5
|
+
// pending unless the convert state marks it done AT ITS CURRENT SIZE (the
|
|
6
|
+
// converter's trust rule, convert.js), because the converter never deletes
|
|
7
|
+
// v1 sources ("files exist" alone is not "migration needed").
|
|
8
|
+
import { statSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { listV1Files, listConvertibleProjects, readConvertState } from './convert.js';
|
|
11
|
+
|
|
12
|
+
/** Pending v1 files + bytes of ONE project dir. */
|
|
13
|
+
function pendingOf(projectDir) {
|
|
14
|
+
const state = readConvertState(projectDir);
|
|
15
|
+
const doneAtSize = new Map(
|
|
16
|
+
(state && Array.isArray(state.files) ? state.files : [])
|
|
17
|
+
.filter((f) => f && f.done)
|
|
18
|
+
.map((f) => [f.name, f.size])
|
|
19
|
+
);
|
|
20
|
+
let files = 0;
|
|
21
|
+
let totalBytes = 0;
|
|
22
|
+
for (const name of listV1Files(projectDir)) {
|
|
23
|
+
let size = 0;
|
|
24
|
+
try { size = statSync(join(projectDir, name)).size; } catch { continue; }
|
|
25
|
+
if (size === 0) continue; // empty shells are not worth prompting over
|
|
26
|
+
if (doneAtSize.get(name) === size) continue; // converted & unchanged
|
|
27
|
+
files++;
|
|
28
|
+
totalBytes += size;
|
|
29
|
+
}
|
|
30
|
+
return { files, totalBytes };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Migration status of one project (plus how many OTHER projects also have
|
|
35
|
+
* pending v1 logs — the prompt mentions `ccv convert --all` for those).
|
|
36
|
+
* @param {string} logDir - LOG_DIR root
|
|
37
|
+
* @param {string} project - project directory name ('' → not pending)
|
|
38
|
+
* @returns {{pending: boolean, files: number, totalBytes: number, otherProjects: number}}
|
|
39
|
+
*/
|
|
40
|
+
export function migrationStatus(logDir, project) {
|
|
41
|
+
const empty = { pending: false, files: 0, totalBytes: 0, otherProjects: 0 };
|
|
42
|
+
if (!logDir || !project) return empty;
|
|
43
|
+
try {
|
|
44
|
+
const { files, totalBytes } = pendingOf(join(logDir, project));
|
|
45
|
+
let otherProjects = 0;
|
|
46
|
+
for (const p of listConvertibleProjects(logDir)) {
|
|
47
|
+
if (p === project) continue;
|
|
48
|
+
if (pendingOf(join(logDir, p)).files > 0) otherProjects++;
|
|
49
|
+
}
|
|
50
|
+
return { pending: files > 0, files, totalBytes, otherProjects };
|
|
51
|
+
} catch {
|
|
52
|
+
return empty;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Wire Format v2 — session-dir readers + MECHANICAL wire replay.
|
|
2
|
+
//
|
|
3
|
+
// Replay here is deliberately interpretation-free: conversation events are
|
|
4
|
+
// applied exactly as the write side derived them from wire state
|
|
5
|
+
// (snapshot → replace state; append → concat; ctl replace-tail → swap last;
|
|
6
|
+
// ctl compact → no-op, the surrounding snapshot already carries the state).
|
|
7
|
+
// This reproduces the EXACT wire messages of every request — byte-equal to
|
|
8
|
+
// what v1's delta reconstruction yields for the same request — without any
|
|
9
|
+
// reverse-anchor/session semantics (those belong to the S5 materializer,
|
|
10
|
+
// which presents conversations; verify only needs wire truth).
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { messageFingerprint, normalizeMsgForEquality } from '../session-boundary.js';
|
|
16
|
+
import { isSupportedWireFormat } from './layout.js';
|
|
17
|
+
|
|
18
|
+
/** Parse a JSONL file tolerating a truncated tail line (spec §14). */
|
|
19
|
+
export function readJsonlTolerant(path) {
|
|
20
|
+
if (!existsSync(path)) return [];
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
23
|
+
const t = line.trim();
|
|
24
|
+
if (!t) continue;
|
|
25
|
+
try { out.push(JSON.parse(t)); } catch { /* truncated tail — drop */ }
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** List session ids under LOG_DIR/<project>/sessions/ (or a staging sibling
|
|
31
|
+
* when the offline converter passes `sessionsDirName`). */
|
|
32
|
+
export function listSessionIds(projectDir, sessionsDirName = 'sessions') {
|
|
33
|
+
const root = join(projectDir, sessionsDirName);
|
|
34
|
+
if (!existsSync(root)) return [];
|
|
35
|
+
return readdirSync(root, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read one session dir into a folded structure:
|
|
40
|
+
* { sessionId, meta, wireFormat, reqs: Map<seq, reqLine>,
|
|
41
|
+
* dones: Map<seq, doneLine>, convEvents: Map<convKey, event[] (sorted by seq)> }
|
|
42
|
+
*
|
|
43
|
+
* Reader version gate (spec §14): the version is taken from meta.json and the
|
|
44
|
+
* journal sentinel (`ph:'meta'` first line). A session stamped with a version
|
|
45
|
+
* this build doesn't understand returns `unsupported: true` with EMPTY folds —
|
|
46
|
+
* callers must refuse it loudly instead of interpreting bytes they can't parse.
|
|
47
|
+
* A missing version (torn creation) is treated as the current one.
|
|
48
|
+
*/
|
|
49
|
+
export function readSession(projectDir, sessionId, sessionsDirName = 'sessions') {
|
|
50
|
+
const dir = join(projectDir, sessionsDirName, sessionId);
|
|
51
|
+
let meta = null;
|
|
52
|
+
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { }
|
|
53
|
+
let wireFormat = meta && typeof meta.wireFormat === 'number' ? meta.wireFormat : null;
|
|
54
|
+
const unsupportedOf = (v) => ({
|
|
55
|
+
sessionId, dir, meta, wireFormat: v, unsupported: true,
|
|
56
|
+
reqs: new Map(), dones: new Map(), convEvents: new Map(),
|
|
57
|
+
});
|
|
58
|
+
if (!isSupportedWireFormat(wireFormat)) return unsupportedOf(wireFormat);
|
|
59
|
+
const reqs = new Map();
|
|
60
|
+
const dones = new Map();
|
|
61
|
+
for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) {
|
|
62
|
+
if (line.ph === 'req') reqs.set(line.seq, line);
|
|
63
|
+
else if (line.ph === 'done' && !dones.has(line.seq)) dones.set(line.seq, line);
|
|
64
|
+
else if (line.ph === 'meta' && typeof line.wireFormat === 'number') {
|
|
65
|
+
// Journal sentinel wins over meta.json (per-file self-description).
|
|
66
|
+
wireFormat = line.wireFormat;
|
|
67
|
+
if (!isSupportedWireFormat(wireFormat)) return unsupportedOf(wireFormat);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const convEvents = new Map();
|
|
71
|
+
const convRoot = join(dir, 'conversations');
|
|
72
|
+
if (existsSync(convRoot)) {
|
|
73
|
+
for (const entry of readdirSync(convRoot, { withFileTypes: true })) {
|
|
74
|
+
if (!entry.isDirectory()) continue;
|
|
75
|
+
const events = [];
|
|
76
|
+
for (const f of readdirSync(join(convRoot, entry.name))) {
|
|
77
|
+
if (!/^e\d+\.jsonl$/.test(f)) continue;
|
|
78
|
+
events.push(...readJsonlTolerant(join(convRoot, entry.name, f)));
|
|
79
|
+
}
|
|
80
|
+
events.sort((a, b) => a.seq - b.seq);
|
|
81
|
+
convEvents.set(entry.name, events);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { sessionId, dir, meta, wireFormat, reqs, dones, convEvents };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Digest of a messages array: sha256 over the per-message fingerprint list.
|
|
88
|
+
* Shared formula between the v1 and v2 sides of verify. Messages are
|
|
89
|
+
* normalized first (S4 decision 2026-07-14): the client's cache_control
|
|
90
|
+
* breakpoint migration rewrites OLD messages (string → block array with
|
|
91
|
+
* cache_control) — v1 delta cannot express that mutation, so without
|
|
92
|
+
* normalization every real active day fails the soak gate on false
|
|
93
|
+
* positives (plan S4 table, 2026-07-13 row). */
|
|
94
|
+
export function messagesDigest(messages) {
|
|
95
|
+
const h = createHash('sha256');
|
|
96
|
+
for (const m of messages) h.update(messageFingerprint(normalizeMsgForEquality(m)) + '\n');
|
|
97
|
+
return h.digest('hex').slice(0, 16);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Same content-hash formula as BlobStore.put — lets verify compare v1 inline
|
|
101
|
+
* tools/system against v2 blob refs WITHOUT reading blob files.
|
|
102
|
+
* KEEP IN SYNC: server/lib/v2/blob-store.js `put` is the writing twin of this
|
|
103
|
+
* formula; any change to hash/slice/serialization must land in both. */
|
|
104
|
+
export function blobRefOf(value) {
|
|
105
|
+
if (value === undefined || value === null) return null;
|
|
106
|
+
return 'sha256-' + createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Mechanically replay one conversation's events, yielding the wire state
|
|
111
|
+
* digest after each event: Map<seq, {digest, len}>. Also returns integrity
|
|
112
|
+
* violations against the journal's msgTo counts (checked by the caller,
|
|
113
|
+
* which owns the journal).
|
|
114
|
+
*/
|
|
115
|
+
export function replayConversation(events) {
|
|
116
|
+
let state = [];
|
|
117
|
+
const bySeq = new Map();
|
|
118
|
+
for (const ev of events) {
|
|
119
|
+
if (ev.t === 'snapshot') {
|
|
120
|
+
state = Array.isArray(ev.msgs) ? ev.msgs : [];
|
|
121
|
+
} else if (ev.t === 'append') {
|
|
122
|
+
if (Array.isArray(ev.msgs) && ev.msgs.length > 0) state = state.concat(ev.msgs);
|
|
123
|
+
} else if (ev.t === 'ctl') {
|
|
124
|
+
if (ev.op === 'replace-tail' && ev.msg && state.length > 0) {
|
|
125
|
+
state = state.slice(0, -1).concat([ev.msg]);
|
|
126
|
+
}
|
|
127
|
+
// 'compact' is a marker for the materializer; wire state came via the
|
|
128
|
+
// snapshot event of the same request.
|
|
129
|
+
}
|
|
130
|
+
bySeq.set(ev.seq, { digest: messagesDigest(state), len: state.length });
|
|
131
|
+
}
|
|
132
|
+
return bySeq;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Build the per-request wire index of one session:
|
|
137
|
+
* Map<`${ts}|${url}`, Array<{seq, kind, conv, epoch, digest, len, toolsRef,
|
|
138
|
+
* sysRef, done, status, msgTo}>>
|
|
139
|
+
* — an ARRAY per key: `${ts}|${url}` is not unique in real logs (same-ms
|
|
140
|
+
* countTokens bursts), and collapsing twins loses their done lines/digests
|
|
141
|
+
* (real-data finding 2026-07-14). Also returns integrity violations
|
|
142
|
+
* (journal msgTo vs replayed length).
|
|
143
|
+
*/
|
|
144
|
+
export function indexSession(session) {
|
|
145
|
+
const replayed = new Map(); // convKey → Map<seq, {digest, len}>
|
|
146
|
+
for (const [convKey, events] of session.convEvents) replayed.set(convKey, replayConversation(events));
|
|
147
|
+
|
|
148
|
+
const index = new Map();
|
|
149
|
+
const integrity = [];
|
|
150
|
+
// Walk reqs in seq order; for reqs without a conv event (unchanged wire),
|
|
151
|
+
// carry the conv's latest replayed state at or before that seq.
|
|
152
|
+
const lastStateOf = new Map(); // convKey → {digest, len}
|
|
153
|
+
const seqs = [...session.reqs.keys()].sort((a, b) => a - b);
|
|
154
|
+
for (const seq of seqs) {
|
|
155
|
+
const req = session.reqs.get(seq);
|
|
156
|
+
const done = session.dones.get(seq) || null;
|
|
157
|
+
let digest = null;
|
|
158
|
+
let len = null;
|
|
159
|
+
if (req.conv) {
|
|
160
|
+
const conv = replayed.get(req.conv);
|
|
161
|
+
const hit = conv && conv.get(seq);
|
|
162
|
+
if (hit) {
|
|
163
|
+
lastStateOf.set(req.conv, hit);
|
|
164
|
+
digest = hit.digest; len = hit.len;
|
|
165
|
+
} else {
|
|
166
|
+
const carried = lastStateOf.get(req.conv);
|
|
167
|
+
if (carried) { digest = carried.digest; len = carried.len; }
|
|
168
|
+
}
|
|
169
|
+
if (typeof req.msgTo === 'number' && len != null && req.msgTo !== len) {
|
|
170
|
+
integrity.push({ sessionId: session.sessionId, seq, conv: req.conv, msgTo: req.msgTo, replayedLen: len });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const key = `${req.ts}|${req.url}`;
|
|
174
|
+
const rec = {
|
|
175
|
+
sessionId: session.sessionId,
|
|
176
|
+
seq,
|
|
177
|
+
kind: req.kind,
|
|
178
|
+
conv: req.conv || null,
|
|
179
|
+
epoch: req.epoch,
|
|
180
|
+
digest,
|
|
181
|
+
len,
|
|
182
|
+
toolsRef: (req.blobs && req.blobs.tools) || null,
|
|
183
|
+
sysRef: (req.blobs && req.blobs.sys) || null,
|
|
184
|
+
done: !!done,
|
|
185
|
+
status: done ? done.status : null,
|
|
186
|
+
};
|
|
187
|
+
const arr = index.get(key);
|
|
188
|
+
if (arr) arr.push(rec); else index.set(key, [rec]);
|
|
189
|
+
}
|
|
190
|
+
return { index, integrity };
|
|
191
|
+
}
|