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,409 @@
|
|
|
1
|
+
// Wire Format v2 — S8 offline v1→v2 converter (docs/refactor/WIRE_FORMAT_V2_PLAN.md §S8).
|
|
2
|
+
//
|
|
3
|
+
// Converts a project's historical v1 JSONL files into v2 session dirs:
|
|
4
|
+
// enumerate *.jsonl ascending by filename timestamp (sessions span
|
|
5
|
+
// rotation files, so order is mandatory) → per file: fresh incremental
|
|
6
|
+
// reconstructor (rotation resets v1 delta state, so every file is
|
|
7
|
+
// self-contained) feeding ONE shared V2Writer bound to the STAGING dir
|
|
8
|
+
// (`sessions-migrating/`) → full golden verify (verifyV1File against the
|
|
9
|
+
// staging root) → promote each staged session into `sessions/` via renameSync.
|
|
10
|
+
//
|
|
11
|
+
// STRICTLY ADDITIVE: v1 files are never written, moved, or deleted. The only
|
|
12
|
+
// writes are the staging tree, the promoted session dirs, and the state file.
|
|
13
|
+
//
|
|
14
|
+
// Resume: file-level checkpoints in `wire-v2-convert-state.json`. A restart
|
|
15
|
+
// re-runs the first incomplete file; sessions spanning the resume boundary are
|
|
16
|
+
// safe by construction (Journal seeds seq from disk, ConversationStore falls
|
|
17
|
+
// back to a full snapshot when the prefix test fails) at a small size cost.
|
|
18
|
+
//
|
|
19
|
+
// Session-level skip: a sid whose real `sessions/<sid>` dir already exists is
|
|
20
|
+
// dual-write output — authoritative, never re-ingested. Convert while
|
|
21
|
+
// dual-write is enabled (the UI says so): then any tail appended to the active
|
|
22
|
+
// v1 file after conversion belongs to dual-written sessions and skipping is
|
|
23
|
+
// exact. (With dual-write off and CC actively writing, the then-active
|
|
24
|
+
// session's post-conversion tail would stay unconverted — documented edge.)
|
|
25
|
+
|
|
26
|
+
import { readdirSync, existsSync, statSync, statfsSync, renameSync, rmSync, readFileSync, mkdirSync } from 'node:fs';
|
|
27
|
+
import { join, basename } from 'node:path';
|
|
28
|
+
import { iterateRawEntriesAsync } from '../log-stream.js';
|
|
29
|
+
import { createIncrementalReconstructor } from '../delta-reconstructor.js';
|
|
30
|
+
import { isLogFileName, parseLogTs } from '../log-management.js';
|
|
31
|
+
import { reportSwallowed } from '../error-report.js';
|
|
32
|
+
import { V2Writer } from './v2-writer.js';
|
|
33
|
+
import { verifyV1File } from './verify.js';
|
|
34
|
+
import { parseUserId } from './identity.js';
|
|
35
|
+
import { sanitizePathComponent, writeFileAtomicSync, sessionPaths } from './layout.js';
|
|
36
|
+
import { listSessionIds } from './replay.js';
|
|
37
|
+
import { resolveSessionDirName } from './session-select.js';
|
|
38
|
+
|
|
39
|
+
export const STAGING_DIR_NAME = 'sessions-migrating';
|
|
40
|
+
// Sessions that fail golden verify are moved here instead of being promoted into
|
|
41
|
+
// `sessions/`. The migration still completes — a single bad session is held for
|
|
42
|
+
// inspection, never a batch-wide abort (weak verify dependency, user 2026-07-15).
|
|
43
|
+
export const QUARANTINE_DIR_NAME = 'sessions-quarantine';
|
|
44
|
+
export const STATE_FILE_NAME = 'wire-v2-convert-state.json';
|
|
45
|
+
const STATE_VERSION = 1;
|
|
46
|
+
const STOP_CHECK_EVERY = 50; // entries between shouldStop() polls
|
|
47
|
+
|
|
48
|
+
/** Enumerate a project's v1 log files, ascending by filename timestamp.
|
|
49
|
+
* Excludes `_temp.jsonl` (resume scratch). */
|
|
50
|
+
export function listV1Files(projectDir) {
|
|
51
|
+
if (!existsSync(projectDir)) return [];
|
|
52
|
+
return readdirSync(projectDir)
|
|
53
|
+
.filter(name => isLogFileName(name) && !name.endsWith('_temp.jsonl'))
|
|
54
|
+
.sort((a, b) => parseLogTs(a).localeCompare(parseLogTs(b)) || a.localeCompare(b));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Projects under logDir that have at least one v1 log file (CLI --all). */
|
|
58
|
+
export function listConvertibleProjects(logDir) {
|
|
59
|
+
if (!existsSync(logDir)) return [];
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const entry of readdirSync(logDir, { withFileTypes: true })) {
|
|
62
|
+
if (!entry.isDirectory()) continue;
|
|
63
|
+
try {
|
|
64
|
+
if (listV1Files(join(logDir, entry.name)).length > 0) out.push(entry.name);
|
|
65
|
+
} catch { /* unreadable project dir — not convertible */ }
|
|
66
|
+
}
|
|
67
|
+
return out.sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readConvertState(projectDir) {
|
|
71
|
+
try {
|
|
72
|
+
const raw = readFileSync(join(projectDir, STATE_FILE_NAME), 'utf-8');
|
|
73
|
+
const state = JSON.parse(raw);
|
|
74
|
+
return state && state.version === STATE_VERSION ? state : null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeConvertState(projectDir, state) {
|
|
81
|
+
writeFileAtomicSync(join(projectDir, STATE_FILE_NAME), JSON.stringify(state, null, 2) + '\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** done-line historical timestamp: request start + duration (falls back to start). */
|
|
85
|
+
function doneTsOf(entry) {
|
|
86
|
+
const start = Date.parse(entry.timestamp);
|
|
87
|
+
if (!Number.isFinite(start)) return entry.timestamp || undefined;
|
|
88
|
+
const dur = typeof entry.duration === 'number' && entry.duration > 0 ? entry.duration : 0;
|
|
89
|
+
return new Date(start + dur).toISOString();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert one project's v1 logs to v2. Long-running; the caller (worker/CLI)
|
|
94
|
+
* owns threading. Progress/state are observable via `onProgress` and the
|
|
95
|
+
* state file. Throws on fatal errors after persisting status:'error'.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} logDir
|
|
98
|
+
* @param {string} project - directory name under logDir (no path separators)
|
|
99
|
+
* @param {object} [opts]
|
|
100
|
+
* @param {Function} [opts.onProgress] - ({phase, file, fileIndex, filesTotal, entries, sessionsConverted, sessionsSkipped})
|
|
101
|
+
* @param {Function} [opts.shouldStop] - polled between entries; true → persist 'stopped' and return
|
|
102
|
+
* @param {boolean} [opts.verify=true] - golden verify before promote (non-fatal:
|
|
103
|
+
* failing sessions are quarantined, not aborted; the migration still completes)
|
|
104
|
+
* @param {Function} [opts.verifyFn] - injected verifyV1File (tests)
|
|
105
|
+
* @param {Function} [opts.statfs] - injected statfsSync (tests)
|
|
106
|
+
* @param {number} [opts.spaceFactor=2] - free-space assertion multiplier
|
|
107
|
+
* @returns {Promise<object>} final state object
|
|
108
|
+
*/
|
|
109
|
+
export async function convertProject(logDir, project, opts = {}) {
|
|
110
|
+
if (!project || project !== sanitizePathComponent(project)) {
|
|
111
|
+
throw new Error(`invalid project name: ${JSON.stringify(project)}`);
|
|
112
|
+
}
|
|
113
|
+
const projectDir = join(logDir, project);
|
|
114
|
+
const onProgress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {};
|
|
115
|
+
const shouldStop = typeof opts.shouldStop === 'function' ? opts.shouldStop : () => false;
|
|
116
|
+
const doVerify = opts.verify !== false;
|
|
117
|
+
const verifyFn = typeof opts.verifyFn === 'function' ? opts.verifyFn : verifyV1File;
|
|
118
|
+
const statfs = opts.statfs || statfsSync;
|
|
119
|
+
const spaceFactor = typeof opts.spaceFactor === 'number' ? opts.spaceFactor : 2;
|
|
120
|
+
|
|
121
|
+
const files = listV1Files(projectDir);
|
|
122
|
+
const prev = readConvertState(projectDir);
|
|
123
|
+
const prevFiles = new Map((prev && Array.isArray(prev.files) ? prev.files : []).map(f => [f.name, f]));
|
|
124
|
+
|
|
125
|
+
const state = {
|
|
126
|
+
version: STATE_VERSION,
|
|
127
|
+
status: 'running',
|
|
128
|
+
startedAt: new Date().toISOString(),
|
|
129
|
+
finishedAt: null,
|
|
130
|
+
currentFile: null,
|
|
131
|
+
// A previously-done file is only trusted at the same size: a grown file
|
|
132
|
+
// (the active log) is re-run in full — its new sessions get converted, its
|
|
133
|
+
// already-promoted/dual-written sessions are skipped at session level.
|
|
134
|
+
files: files.map(name => {
|
|
135
|
+
const old = prevFiles.get(name);
|
|
136
|
+
let size = 0;
|
|
137
|
+
try { size = statSync(join(projectDir, name)).size; } catch { }
|
|
138
|
+
const done = !!(old && old.done && old.size === size);
|
|
139
|
+
return { name, size, done };
|
|
140
|
+
}),
|
|
141
|
+
sessionsConverted: prev && prev.status !== 'done' ? (prev.sessionsConverted || 0) : 0,
|
|
142
|
+
sessionsSkipped: 0,
|
|
143
|
+
sessionsQuarantined: 0,
|
|
144
|
+
quarantined: [], // [{ sessionId (dir name), uuid, reasons: string[] }] — held-back sessions
|
|
145
|
+
entries: 0,
|
|
146
|
+
// Verify-phase progress, persisted so the frontend (which renders from the
|
|
147
|
+
// state file only) can show "verifying (x/n) · m entries" instead of a
|
|
148
|
+
// static label. verifyEntries accumulates ACROSS files (the moving signal
|
|
149
|
+
// when one big file dominates); all three keep their final values on done.
|
|
150
|
+
verifyIndex: 0,
|
|
151
|
+
verifyTotal: 0,
|
|
152
|
+
verifyEntries: 0,
|
|
153
|
+
lastError: null,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// Free-space assertion: staging + promoted copies of everything still pending.
|
|
157
|
+
const pendingBytes = state.files.filter(f => !f.done).reduce((a, f) => a + f.size, 0);
|
|
158
|
+
try {
|
|
159
|
+
const st = statfs(logDir);
|
|
160
|
+
const free = Number(st.bavail) * Number(st.bsize);
|
|
161
|
+
if (free < pendingBytes * spaceFactor) {
|
|
162
|
+
state.status = 'error';
|
|
163
|
+
state.lastError = `insufficient disk space: ${free} bytes free < ${spaceFactor}x pending ${pendingBytes} bytes`;
|
|
164
|
+
state.finishedAt = new Date().toISOString();
|
|
165
|
+
writeConvertState(projectDir, state);
|
|
166
|
+
throw new Error(state.lastError);
|
|
167
|
+
}
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (state.status === 'error') throw err;
|
|
170
|
+
reportSwallowed('v2-convert.statfs', err); // statfs failure alone doesn't block conversion
|
|
171
|
+
}
|
|
172
|
+
writeConvertState(projectDir, state);
|
|
173
|
+
|
|
174
|
+
const writer = new V2Writer({
|
|
175
|
+
logDir,
|
|
176
|
+
project,
|
|
177
|
+
enabled: true,
|
|
178
|
+
sessionsDirName: STAGING_DIR_NAME,
|
|
179
|
+
metaExtra: { origin: 'convert', convertedAt: new Date().toISOString() },
|
|
180
|
+
exactConvFps: true, // golden gate replays byte-identical wire — 80-char snips are too coarse
|
|
181
|
+
statfs,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const skipSids = new Set(); // dual-write-authoritative sessions
|
|
185
|
+
const seenSids = new Set();
|
|
186
|
+
const sidSources = new Map(); // sid → Set<file name> (stamped into meta at promote)
|
|
187
|
+
// Cold-start hold replay: sid-less entries at a file head return a null
|
|
188
|
+
// handle (V2Writer parks them until the first sid appears); calling
|
|
189
|
+
// ingestCompletion right away would look up _lateHandles BEFORE that flush
|
|
190
|
+
// and silently drop the done line. Buffer those completions and replay them
|
|
191
|
+
// after the first successful ingest (real-data finding 2026-07-14). Bounded
|
|
192
|
+
// by the writer's own 64-entry hold queue.
|
|
193
|
+
const pendingDone = [];
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
for (let i = 0; i < state.files.length; i++) {
|
|
197
|
+
const f = state.files[i];
|
|
198
|
+
if (f.done) continue;
|
|
199
|
+
if (shouldStop()) { // file-boundary stop: exact even when files are tiny
|
|
200
|
+
await writer.flush();
|
|
201
|
+
state.status = 'stopped';
|
|
202
|
+
state.finishedAt = new Date().toISOString();
|
|
203
|
+
writeConvertState(projectDir, state);
|
|
204
|
+
return state;
|
|
205
|
+
}
|
|
206
|
+
state.currentFile = f.name;
|
|
207
|
+
writeConvertState(projectDir, state);
|
|
208
|
+
|
|
209
|
+
const filePath = join(projectDir, f.name);
|
|
210
|
+
// Fresh per-file reconstructor: rotation resets the v1 delta state, so the
|
|
211
|
+
// first mainAgent entry of every file is a full checkpoint (interceptor.js
|
|
212
|
+
// checkAndRotateLogFile) — no cross-file reconstruction state is needed.
|
|
213
|
+
const reconstructor = createIncrementalReconstructor();
|
|
214
|
+
let sinceStopCheck = 0;
|
|
215
|
+
|
|
216
|
+
for await (const raw of iterateRawEntriesAsync(filePath)) {
|
|
217
|
+
if (++sinceStopCheck >= STOP_CHECK_EVERY) {
|
|
218
|
+
sinceStopCheck = 0;
|
|
219
|
+
if (shouldStop()) {
|
|
220
|
+
await writer.flush(); // stopped-state on disk must not be ahead of the writes it describes
|
|
221
|
+
state.status = 'stopped';
|
|
222
|
+
state.finishedAt = new Date().toISOString();
|
|
223
|
+
writeConvertState(projectDir, state);
|
|
224
|
+
return state;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let entry;
|
|
228
|
+
try { entry = JSON.parse(raw); } catch { continue; } // torn tail frame
|
|
229
|
+
if (entry.ccvRotationContext || entry.inProgress) continue;
|
|
230
|
+
reconstructor.reconstruct(entry);
|
|
231
|
+
if (entry._staleReorder || entry._reconstructBroken) continue; // poisoned twin; the good copy converts
|
|
232
|
+
|
|
233
|
+
const parsed = parseUserId(entry.body && entry.body.metadata && entry.body.metadata.user_id);
|
|
234
|
+
const sid = parsed ? parsed.sessionId : null;
|
|
235
|
+
if (sid) {
|
|
236
|
+
if (!seenSids.has(sid)) {
|
|
237
|
+
seenSids.add(sid);
|
|
238
|
+
// Task C: a live dual-written session's dir is `<ts>_<uuid>`, so
|
|
239
|
+
// match by UUID (resolve-by-scan), not a bare `sessions/<uuid>` path.
|
|
240
|
+
if (resolveSessionDirName(projectDir, sid)) {
|
|
241
|
+
skipSids.add(sid);
|
|
242
|
+
state.sessionsSkipped++;
|
|
243
|
+
} else {
|
|
244
|
+
state.sessionsConverted++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (skipSids.has(sid)) continue;
|
|
248
|
+
let src = sidSources.get(sid);
|
|
249
|
+
if (!src) sidSources.set(sid, src = new Set());
|
|
250
|
+
src.add(f.name);
|
|
251
|
+
}
|
|
252
|
+
// Metadata-less entries (heartbeat/countTokens) fall through to the
|
|
253
|
+
// writer's _currentSid routing — same semantics as the live path.
|
|
254
|
+
// Old entries (pre-requestId era) get a synthetic rid: the cold-start
|
|
255
|
+
// hold queue recovers completions by rid, and a null rid would leave
|
|
256
|
+
// held requests without their done line (real-data finding 2026-07-14).
|
|
257
|
+
if (!entry.requestId) entry.requestId = `cvt_${i}_${state.entries}`;
|
|
258
|
+
const handle = writer.ingestRequest(entry, entry.body && entry.body.messages);
|
|
259
|
+
if (handle) {
|
|
260
|
+
writer.ingestCompletion(handle, entry, { doneTs: doneTsOf(entry) });
|
|
261
|
+
if (pendingDone.length > 0) {
|
|
262
|
+
// The hold queue has flushed (a sid resolved) — late handles exist now.
|
|
263
|
+
for (const p of pendingDone.splice(0)) writer.ingestCompletion(null, p.entry, p.opts);
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
pendingDone.push({ entry, opts: { doneTs: doneTsOf(entry) } });
|
|
267
|
+
}
|
|
268
|
+
state.entries++;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await writer.flush(); // file checkpoint must not be ahead of its writes
|
|
272
|
+
f.done = true;
|
|
273
|
+
state.currentFile = null;
|
|
274
|
+
writeConvertState(projectDir, state);
|
|
275
|
+
onProgress({ phase: 'convert', file: f.name, fileIndex: i + 1, filesTotal: state.files.length, entries: state.entries, sessionsConverted: state.sessionsConverted, sessionsSkipped: state.sessionsSkipped });
|
|
276
|
+
}
|
|
277
|
+
await writer.flush();
|
|
278
|
+
|
|
279
|
+
// ---- golden verify (staging) — weak, per-session dependency -------------
|
|
280
|
+
// Verify no longer gates the whole batch: a diff/integrity violation flags
|
|
281
|
+
// only the SPECIFIC staged session that produced it (verify attributes every
|
|
282
|
+
// diff to its sessionId). Those sessions are quarantined below; everything
|
|
283
|
+
// clean still promotes. One bad session never aborts the migration
|
|
284
|
+
// (user 2026-07-15). `suspectSessions` is uncapped, so a >50-diff file still
|
|
285
|
+
// yields the complete quarantine set.
|
|
286
|
+
const quarantineSids = new Map(); // staged dir name → reasons[] (merged across files)
|
|
287
|
+
if (doVerify && listSessionIds(projectDir, STAGING_DIR_NAME).length > 0) {
|
|
288
|
+
state.status = 'verifying';
|
|
289
|
+
state.verifyTotal = state.files.length;
|
|
290
|
+
writeConvertState(projectDir, state);
|
|
291
|
+
// Per-entry progress: verifyEntries accumulates across files; state-file
|
|
292
|
+
// writes are time-throttled (≥1s) so a huge file can't turn the scan into
|
|
293
|
+
// an fsync storm. The per-FILE advance below is written unconditionally.
|
|
294
|
+
let entriesBase = 0;
|
|
295
|
+
let lastEntriesWrite = 0;
|
|
296
|
+
for (let i = 0; i < state.files.length; i++) {
|
|
297
|
+
const f = state.files[i];
|
|
298
|
+
let report = null;
|
|
299
|
+
try {
|
|
300
|
+
report = await verifyFn(join(projectDir, f.name), {
|
|
301
|
+
sessionsDirName: STAGING_DIR_NAME,
|
|
302
|
+
onProgress: ({ entriesScanned }) => {
|
|
303
|
+
state.verifyEntries = entriesBase + (entriesScanned || 0);
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
if (now - lastEntriesWrite >= 1000) {
|
|
306
|
+
lastEntriesWrite = now;
|
|
307
|
+
writeConvertState(projectDir, state);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
} catch (err) {
|
|
312
|
+
// Verify is a soft check — a crash in the verifier itself (e.g. an
|
|
313
|
+
// unreadable staged session) must not sink the migration. Skip this
|
|
314
|
+
// file's attribution and keep going; unverified sessions still promote.
|
|
315
|
+
reportSwallowed('v2-convert.verify', err);
|
|
316
|
+
}
|
|
317
|
+
if (report) {
|
|
318
|
+
for (const s of report.suspectSessions || []) {
|
|
319
|
+
const prev = quarantineSids.get(s.sessionId) || [];
|
|
320
|
+
quarantineSids.set(s.sessionId, [...prev, ...(s.reasons || [])].slice(0, 10));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Advance the persisted counter even when verifyFn threw — x must reach
|
|
324
|
+
// n at the end of the loop, never skip numbers.
|
|
325
|
+
entriesBase = state.verifyEntries;
|
|
326
|
+
state.verifyIndex = i + 1;
|
|
327
|
+
writeConvertState(projectDir, state);
|
|
328
|
+
onProgress({ phase: 'verify', file: f.name, fileIndex: i + 1, filesTotal: state.files.length, entries: state.entries, sessionsConverted: state.sessionsConverted, sessionsSkipped: state.sessionsSkipped, quarantined: quarantineSids.size });
|
|
329
|
+
if (shouldStop()) {
|
|
330
|
+
state.status = 'stopped';
|
|
331
|
+
state.finishedAt = new Date().toISOString();
|
|
332
|
+
writeConvertState(projectDir, state);
|
|
333
|
+
return state;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---- promote staging → sessions/ (same volume, per-session atomic) ------
|
|
339
|
+
const stagingRoot = join(projectDir, STAGING_DIR_NAME);
|
|
340
|
+
const quarantineRoot = join(projectDir, QUARANTINE_DIR_NAME);
|
|
341
|
+
if (listSessionIds(projectDir, STAGING_DIR_NAME).length > 0) {
|
|
342
|
+
mkdirSync(join(projectDir, 'sessions'), { recursive: true }); // fresh project: parent may not exist yet
|
|
343
|
+
}
|
|
344
|
+
for (const sid of listSessionIds(projectDir, STAGING_DIR_NAME)) {
|
|
345
|
+
const staged = join(stagingRoot, sid);
|
|
346
|
+
// Task C: `sid` is the staged dir name `<ts>_<uuid>`. A LIVE dual-write dir
|
|
347
|
+
// for the same UUID may carry a DIFFERENT ts prefix, so detect the
|
|
348
|
+
// collision by UUID (from the staged meta.sessionId), not the exact name.
|
|
349
|
+
let stagedUuid = sid;
|
|
350
|
+
try { stagedUuid = JSON.parse(readFileSync(join(staged, 'meta.json'), 'utf-8')).sessionId || sid; } catch { /* fall back to name */ }
|
|
351
|
+
if (resolveSessionDirName(projectDir, stagedUuid)) {
|
|
352
|
+
// A dual-write session appeared for this uuid mid-conversion — live data
|
|
353
|
+
// wins (authoritative), so drop the staged copy even if it was flagged.
|
|
354
|
+
rmSync(staged, { recursive: true, force: true });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
// Verify flagged this session: hold it in `sessions-quarantine/` for
|
|
358
|
+
// inspection instead of promoting corrupt data into the live view. The
|
|
359
|
+
// migration still completes; the user is told which sessions were held.
|
|
360
|
+
if (quarantineSids.has(sid)) {
|
|
361
|
+
mkdirSync(quarantineRoot, { recursive: true });
|
|
362
|
+
const held = join(quarantineRoot, sid);
|
|
363
|
+
rmSync(held, { recursive: true, force: true }); // idempotent across re-runs
|
|
364
|
+
renameSync(staged, held);
|
|
365
|
+
state.sessionsQuarantined++;
|
|
366
|
+
state.quarantined.push({ sessionId: sid, uuid: stagedUuid, reasons: quarantineSids.get(sid) });
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const target = sessionPaths(logDir, project, sid).dir;
|
|
370
|
+
// sidSources is keyed by the UUID (ingest time), not the staged dir name.
|
|
371
|
+
const src = sidSources.get(stagedUuid);
|
|
372
|
+
if (src && src.size > 0) {
|
|
373
|
+
try {
|
|
374
|
+
const metaPath = join(staged, 'meta.json');
|
|
375
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
376
|
+
meta.sources = [...src].sort();
|
|
377
|
+
writeFileAtomicSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
378
|
+
} catch (err) {
|
|
379
|
+
reportSwallowed('v2-convert.meta-sources', err); // sources are advisory
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
renameSync(staged, target);
|
|
383
|
+
}
|
|
384
|
+
// Every staged session has been renamed away or dropped above — anything
|
|
385
|
+
// left is junk (.DS_Store from Finder browsing, real-data 2026-07-14).
|
|
386
|
+
// Guard on "no session dirs remain" before the recursive removal.
|
|
387
|
+
try {
|
|
388
|
+
if (existsSync(stagingRoot) && listSessionIds(projectDir, STAGING_DIR_NAME).length === 0) {
|
|
389
|
+
rmSync(stagingRoot, { recursive: true, force: true });
|
|
390
|
+
}
|
|
391
|
+
} catch { /* leftover staging dir is cosmetic — never fail a completed migration on it */ }
|
|
392
|
+
|
|
393
|
+
state.status = 'done';
|
|
394
|
+
state.finishedAt = new Date().toISOString();
|
|
395
|
+
writeConvertState(projectDir, state);
|
|
396
|
+
onProgress({ phase: 'done', filesTotal: state.files.length, entries: state.entries, sessionsConverted: state.sessionsConverted, sessionsSkipped: state.sessionsSkipped, quarantined: state.sessionsQuarantined });
|
|
397
|
+
return state;
|
|
398
|
+
} catch (err) {
|
|
399
|
+
if (state.status !== 'error') {
|
|
400
|
+
state.status = 'error';
|
|
401
|
+
state.lastError = String(err && err.message || err);
|
|
402
|
+
state.finishedAt = new Date().toISOString();
|
|
403
|
+
try { writeConvertState(projectDir, state); } catch { }
|
|
404
|
+
}
|
|
405
|
+
throw err;
|
|
406
|
+
} finally {
|
|
407
|
+
await writer.close();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Wire Format v2 — identity resolution (docs/refactor/WIRE_FORMAT_V2.md §8/§10).
|
|
2
|
+
//
|
|
3
|
+
// Two jobs, both pure except for the small per-process state the caller owns:
|
|
4
|
+
// 1. parseUserId — extract session_id from body.metadata.user_id, which exists
|
|
5
|
+
// in TWO wire encodings (JSON string in newer CC; underscore-delimited in
|
|
6
|
+
// older CC — both present in real logs, verified during planning).
|
|
7
|
+
// 2. ConvResolver — derive a stable conversation key for every request:
|
|
8
|
+
// main / sub-<toolUseId> / sub-fp-<fingerprint> / misc, disambiguating
|
|
9
|
+
// parallel same-prompt subagents via the spawning Agent block's tool_use.id
|
|
10
|
+
// (which v1's prompt-prefix registry loses, plan risk #7).
|
|
11
|
+
|
|
12
|
+
// Matches v1's extractAgentSpawnPairs keying so both sides observe the same
|
|
13
|
+
// prefix (server/lib/interceptor-core.js TEAMMATE_PROMPT_PREFIX_LEN).
|
|
14
|
+
export const SPAWN_PROMPT_PREFIX_LEN = 60;
|
|
15
|
+
|
|
16
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse a raw metadata.user_id string into { sessionId, encoding } or null.
|
|
20
|
+
* Encodings (spec §8):
|
|
21
|
+
* - 'json': '{"device_id":…,"account_uuid":…,"session_id":"<uuid>"}'
|
|
22
|
+
* - 'delimited': 'user_<hash>_account_<acct?>_session_<uuid>'
|
|
23
|
+
*/
|
|
24
|
+
export function parseUserId(userIdRaw) {
|
|
25
|
+
if (typeof userIdRaw !== 'string' || userIdRaw === '') return null;
|
|
26
|
+
try {
|
|
27
|
+
const obj = JSON.parse(userIdRaw);
|
|
28
|
+
if (obj && typeof obj.session_id === 'string' && obj.session_id !== '') {
|
|
29
|
+
return { sessionId: obj.session_id, encoding: 'json' };
|
|
30
|
+
}
|
|
31
|
+
return null; // valid JSON but no session_id — treat as unparseable
|
|
32
|
+
} catch { /* not JSON → try the delimited form */ }
|
|
33
|
+
const idx = userIdRaw.lastIndexOf('_session_');
|
|
34
|
+
if (idx >= 0) {
|
|
35
|
+
const tail = userIdRaw.slice(idx + '_session_'.length);
|
|
36
|
+
if (UUID_RE.test(tail)) return { sessionId: tail, encoding: 'delimited' };
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const REMINDER_OPEN = '<system-reminder>';
|
|
42
|
+
const REMINDER_CLOSE = '</system-reminder>';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Strip harness-injected <system-reminder>…</system-reminder> preambles from
|
|
46
|
+
* the head of a prompt. The harness prepends them to a subagent's first user
|
|
47
|
+
* message on the wire, but Agent/Task tool_use.input.prompt (the spawn-registry
|
|
48
|
+
* key) never contains them — without stripping, the prompt-prefix lookup gets
|
|
49
|
+
* 0 hits and every parallel sub shares one boilerplate fingerprint (real-data
|
|
50
|
+
* finding, plan "关键事实" 2026-07-14). Only leading reminders are removed:
|
|
51
|
+
* callers fingerprint the prompt START.
|
|
52
|
+
*/
|
|
53
|
+
function stripLeadingReminders(text) {
|
|
54
|
+
let t = text;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const s = t.trimStart();
|
|
57
|
+
if (!s.startsWith(REMINDER_OPEN)) return s;
|
|
58
|
+
const end = s.indexOf(REMINDER_CLOSE);
|
|
59
|
+
if (end < 0) return ''; // unterminated reminder — no usable prompt text
|
|
60
|
+
t = s.slice(end + REMINDER_CLOSE.length);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* First user message's prompt text (string or concatenated text blocks),
|
|
66
|
+
* with leading <system-reminder> preambles stripped and whitespace trimmed
|
|
67
|
+
* at the start.
|
|
68
|
+
*/
|
|
69
|
+
export function firstUserPromptText(messages) {
|
|
70
|
+
if (!Array.isArray(messages)) return '';
|
|
71
|
+
for (const m of messages) {
|
|
72
|
+
if (!m || m.role !== 'user') continue;
|
|
73
|
+
const c = m.content;
|
|
74
|
+
if (typeof c === 'string') return stripLeadingReminders(c);
|
|
75
|
+
if (Array.isArray(c)) {
|
|
76
|
+
let text = '';
|
|
77
|
+
for (const block of c) {
|
|
78
|
+
if (block && block.type === 'text' && typeof block.text === 'string') text += block.text;
|
|
79
|
+
}
|
|
80
|
+
return stripLeadingReminders(text);
|
|
81
|
+
}
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Cheap non-cryptographic fingerprint for the fp-fallback conv key. */
|
|
88
|
+
export function promptFingerprint(text) {
|
|
89
|
+
let h = 0x811c9dc5; // FNV-1a 32-bit
|
|
90
|
+
const s = String(text);
|
|
91
|
+
for (let i = 0; i < s.length; i++) {
|
|
92
|
+
h ^= s.charCodeAt(i);
|
|
93
|
+
h = (h * 0x01000193) >>> 0;
|
|
94
|
+
}
|
|
95
|
+
return h.toString(16).padStart(8, '0');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Conversation-key resolver for one v2 session. Owns:
|
|
100
|
+
* - spawn registry: prompt prefix → FIFO of unclaimed Agent tool_use.ids
|
|
101
|
+
* (a queue, not a single slot — parallel same-prompt spawns each hold their
|
|
102
|
+
* own id and are claimed in order, unlike v1's overwrite-on-collision map);
|
|
103
|
+
* - active sub-conversation states for continuity: an incoming sub request
|
|
104
|
+
* belongs to the existing conversation whose message count it extends.
|
|
105
|
+
*/
|
|
106
|
+
export class ConvResolver {
|
|
107
|
+
constructor() {
|
|
108
|
+
this._spawnIds = new Map(); // promptPrefix → [toolUseId, ...] unclaimed FIFO
|
|
109
|
+
this._subs = new Map(); // convKey → { firstFp, count }
|
|
110
|
+
this._fpOrdinal = new Map(); // firstFp → next ordinal for fp-fallback keys
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Register Agent-spawn pairs from a completed response body (assistant
|
|
115
|
+
* content blocks with type 'tool_use' and name 'Agent'/'Task'). Same keying
|
|
116
|
+
* idea as v1 extractAgentSpawnPairs but keeps the block id (spec §10) and
|
|
117
|
+
* covers 'Task' too (v1's extractor only handles 'Agent'; 'Task' is a
|
|
118
|
+
* recognized spawn tool elsewhere in the codebase, e.g. interceptor-core).
|
|
119
|
+
*/
|
|
120
|
+
registerSpawns(responseBody) {
|
|
121
|
+
const content = responseBody && Array.isArray(responseBody.content) ? responseBody.content : null;
|
|
122
|
+
if (!content) return;
|
|
123
|
+
for (const block of content) {
|
|
124
|
+
if (!block || block.type !== 'tool_use') continue;
|
|
125
|
+
if (block.name !== 'Agent' && block.name !== 'Task') continue;
|
|
126
|
+
const prompt = block.input && typeof block.input.prompt === 'string' ? block.input.prompt : '';
|
|
127
|
+
if (!prompt || typeof block.id !== 'string' || block.id === '') continue;
|
|
128
|
+
const prefix = prompt.trimStart().slice(0, SPAWN_PROMPT_PREFIX_LEN);
|
|
129
|
+
if (!this._spawnIds.has(prefix)) this._spawnIds.set(prefix, []);
|
|
130
|
+
this._spawnIds.get(prefix).push(block.id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the conv key for a non-main request's messages array.
|
|
136
|
+
* Continuity first (an existing sub whose count this request extends or
|
|
137
|
+
* re-sends), then a fresh key: claimed tool_use.id if the spawn registry has
|
|
138
|
+
* one for this prompt prefix, else fp-fallback with an ordinal suffix so
|
|
139
|
+
* parallel identical prompts without a registered id still split apart.
|
|
140
|
+
*/
|
|
141
|
+
resolveSub(messages) {
|
|
142
|
+
const prompt = firstUserPromptText(messages);
|
|
143
|
+
const fp = promptFingerprint(prompt.trimStart().slice(0, 200));
|
|
144
|
+
const len = Array.isArray(messages) ? messages.length : 0;
|
|
145
|
+
|
|
146
|
+
// Continuity: same first-message fp AND the message count grows (or stays,
|
|
147
|
+
// for retries) relative to that conversation's last seen count. Prefer the
|
|
148
|
+
// candidate with the highest count still <= len (the closest ancestor).
|
|
149
|
+
// Same-length tie-break: while an UNCLAIMED spawn id exists for this prompt
|
|
150
|
+
// prefix, a same-length duplicate is a parallel sibling spawn, not a retry —
|
|
151
|
+
// claim the fresh id instead of stealing the sibling's conversation.
|
|
152
|
+
// Residual mis-attribution edges (accepted; keys stay distinct, only the
|
|
153
|
+
// label can be wrong): (1) a same-length RETRY racing an unclaimed sibling
|
|
154
|
+
// spawn of the identical prompt claims the sibling's id; (2) a fresh
|
|
155
|
+
// sibling whose first request already has MORE messages than an existing
|
|
156
|
+
// sibling's count extends that one instead of opening its own key.
|
|
157
|
+
const prefix = prompt.trimStart().slice(0, SPAWN_PROMPT_PREFIX_LEN);
|
|
158
|
+
const pendingSpawn = this._spawnIds.has(prefix);
|
|
159
|
+
let best = null;
|
|
160
|
+
for (const [key, st] of this._subs) {
|
|
161
|
+
if (st.firstFp !== fp) continue;
|
|
162
|
+
const extend = len > st.count || (len === st.count && !pendingSpawn);
|
|
163
|
+
if (extend && (!best || st.count > best.st.count)) best = { key, st };
|
|
164
|
+
}
|
|
165
|
+
if (best) {
|
|
166
|
+
best.st.count = len;
|
|
167
|
+
return { convKey: best.key, isNew: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// New sub conversation — prefer the spawning Agent block's tool_use.id.
|
|
171
|
+
const queue = this._spawnIds.get(prefix);
|
|
172
|
+
let convKey;
|
|
173
|
+
if (queue && queue.length > 0) {
|
|
174
|
+
convKey = `sub-${queue.shift().slice(-12)}`;
|
|
175
|
+
if (queue.length === 0) this._spawnIds.delete(prefix);
|
|
176
|
+
} else {
|
|
177
|
+
const n = (this._fpOrdinal.get(fp) || 0) + 1;
|
|
178
|
+
this._fpOrdinal.set(fp, n);
|
|
179
|
+
convKey = n === 1 ? `sub-fp-${fp}` : `sub-fp-${fp}-${n}`;
|
|
180
|
+
}
|
|
181
|
+
this._subs.set(convKey, { firstFp: fp, count: len });
|
|
182
|
+
return { convKey, isNew: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
reset() {
|
|
186
|
+
this._spawnIds.clear();
|
|
187
|
+
this._subs.clear();
|
|
188
|
+
this._fpOrdinal.clear();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Classify a request entry into a journal `kind` (spec §4). `entry` is the v1
|
|
194
|
+
* requestEntry shape at initiation time (isHeartbeat/isCountTokens/mainAgent/
|
|
195
|
+
* teammate already stamped by the interceptor).
|
|
196
|
+
*/
|
|
197
|
+
export function classifyKind(entry) {
|
|
198
|
+
if (entry.isHeartbeat) return 'heartbeat';
|
|
199
|
+
if (entry.isCountTokens) return 'countTokens';
|
|
200
|
+
if (entry.teammate) return 'teammate';
|
|
201
|
+
if (entry.mainAgent) return 'main';
|
|
202
|
+
if (entry.body && Array.isArray(entry.body.messages)) return 'sub';
|
|
203
|
+
return 'misc';
|
|
204
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Wire Format v2 — journal writer (docs/refactor/WIRE_FORMAT_V2.md §4).
|
|
2
|
+
//
|
|
3
|
+
// The journal is the single ordering axis. Two-phase, append-only:
|
|
4
|
+
// req line — built in the SAME synchronous segment as the request initiation
|
|
5
|
+
// (the caller allocates seq via nextSeq() there; enqueuing later
|
|
6
|
+
// is fine, MUTATING history lines is not);
|
|
7
|
+
// done line — on completion/error.
|
|
8
|
+
// The reader folds the two phases by seq; a req without a done is in-flight
|
|
9
|
+
// (or the process died) — that IS the v1 placeholder, minus the duplicated body.
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
export class Journal {
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} paths - sessionPaths() object
|
|
16
|
+
* @param {{appendTo: Function}} queue - AsyncWriteQueue (explicit-path API)
|
|
17
|
+
*/
|
|
18
|
+
constructor(paths, queue) {
|
|
19
|
+
this._paths = paths;
|
|
20
|
+
this._queue = queue;
|
|
21
|
+
// Seed from any existing journal so seq stays monotonic per session FILE,
|
|
22
|
+
// not per Journal instance (spec §4 "session 内单调"). Without this, a
|
|
23
|
+
// session-object recreation onto the same dir (workspace switch A→B→A,
|
|
24
|
+
// resetSessions, process re-attach) would restart at 1 and collide with
|
|
25
|
+
// prior lines — the reader folds by seq, so collisions silently corrupt.
|
|
26
|
+
// Journal lines are small; a one-time full read at session bind is cheap.
|
|
27
|
+
this._seq = Journal._maxSeqIn(paths.journalPath);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static _maxSeqIn(journalPath) {
|
|
31
|
+
try {
|
|
32
|
+
if (!existsSync(journalPath)) return 0;
|
|
33
|
+
let max = 0;
|
|
34
|
+
// Regex scan instead of per-line JSON.parse: tolerant of a truncated
|
|
35
|
+
// tail line and ~10x cheaper on large journals.
|
|
36
|
+
for (const m of readFileSync(journalPath, 'utf-8').matchAll(/"seq":\s*(\d+)/g)) {
|
|
37
|
+
const n = Number(m[1]);
|
|
38
|
+
if (n > max) max = n;
|
|
39
|
+
}
|
|
40
|
+
return max;
|
|
41
|
+
} catch {
|
|
42
|
+
return 0; // unreadable journal → fresh numbering beats crashing the writer
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Allocate the next initiation-order seq. MUST be called in the request's
|
|
47
|
+
* synchronous initiation segment (§3.7 guard — completion order must never
|
|
48
|
+
* become the semantic order). */
|
|
49
|
+
nextSeq() {
|
|
50
|
+
return ++this._seq;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Enqueue a req-phase line. `fields` carries the spec §4 req schema minus ph. */
|
|
54
|
+
writeReq(fields, onDone) {
|
|
55
|
+
this._queue.appendTo(this._paths.journalPath, JSON.stringify({ ph: 'req', ...fields }) + '\n', onDone);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Enqueue a done-phase line. */
|
|
59
|
+
writeDone(fields, onDone) {
|
|
60
|
+
this._queue.appendTo(this._paths.journalPath, JSON.stringify({ ph: 'done', ...fields }) + '\n', onDone);
|
|
61
|
+
}
|
|
62
|
+
}
|