chainlesschain 0.162.158 → 0.162.159
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/package.json +1 -1
- package/src/assets/web-panel/assets/{AIOps-BwpTJTN_.js → AIOps-ByIiF5bT.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-BtQOoo3l.js → ActionButton-NlgSFZq5.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-Duf_rXZa.js → Analytics-D17cOUcb.js} +3 -3
- package/src/assets/web-panel/assets/{AppLayout-XQK3wjvB.js → AppLayout-B4mEZcbl.js} +4 -4
- package/src/assets/web-panel/assets/Artifacts-CASsmyQw.js +1 -0
- package/src/assets/web-panel/assets/{Audit-DNjOGQLh.js → Audit-DYAia2e2.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-FH9RAfB5.js → BackgroundAgents-C1lvYUSu.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-ZIbrR4-U.js → Backup-B6agK6bA.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-DSt3NaND.js → BaseInput-DM43R2vy.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-CY-wvbjv.js → Chat-Dtdp3eXp.js} +5 -5
- package/src/assets/web-panel/assets/{ChatBubbleRenderer-BAV9V-t-.js → ChatBubbleRenderer-BL5c_OQ1.js} +1 -1
- package/src/assets/web-panel/assets/{Checkbox-8JVadE_B.js → Checkbox-D2xf7uZt.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-CN83nPYI.js → Codegen-D3j45jJL.js} +1 -1
- package/src/assets/web-panel/assets/{Col-xPSvqdN8.js → Col-voPJ9fUZ.js} +1 -1
- package/src/assets/web-panel/assets/{Community-D78cnIjG.js → Community-DEsyTESF.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-CSw6Csbp.js → Compact-OHV_QXLD.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-E_HhjcYR.js → Compliance-eDTJWC-J.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-DybeQjmm.js → Cowork-BBvy-6d1.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-DS0BJeLY.js → Cron-Dw2eoJxu.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BOwVgkbs.js → Crosschain-DQHms_Bh.js} +1 -1
- package/src/assets/web-panel/assets/{DID-DRUr9YuX.js → DID-Cyu7Id1c.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Cht-yUKW.js → Dashboard-sIDzkG92.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-BLr_EJ9X.js → Dropdown-DUW3T2ey.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-Drexuh5W.js → EmailListRenderer-CECELXW8.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-D9Q2Kq63.js → FamilyGuardDashboard-BIZR37ov.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-C-Z1xI-T.js → Federation-BEZIQX84.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-DLHPMkQF.js → FormItemContext-BcdUMznL.js} +1 -1
- package/src/assets/web-panel/assets/{GenericCardRenderer-DxxEdn2r.js → GenericCardRenderer-DsN_fr5H.js} +1 -1
- package/src/assets/web-panel/assets/{Git-syATcaBt.js → Git-63LhjBx2.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-CYe_r8Zf.js → Governance-CYtQtIcH.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-GWxErB7k.js → Inference-BwR6UUox.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-qQvVCZoi.js → KnowledgeGraph-D9lVNTUA.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-BenAU2W8.js → Logs-C4q5h7Ut.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-7agE3Or1.js → MarkdownRenderer-DexLlXfN.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-D4Jn5fpD.js → Marketplace-Plksliuw.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-DtAwoqiQ.js → McpTools-DZUFfeuG.js} +4 -4
- package/src/assets/web-panel/assets/{Memory-BI_O50I8.js → Memory-NoDYt29V.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-CQWE2lqx.js → MobileBridge-CEXGTkdh.js} +1 -1
- package/src/assets/web-panel/assets/{MobileProjects-CVtTqgtg.js → MobileProjects-DnbJkbZo.js} +1 -1
- package/src/assets/web-panel/assets/{Mtc-Dnr16qsn.js → Mtc-DTAa0cwy.js} +5 -5
- package/src/assets/web-panel/assets/{MtcAudit-CQ5Xe4-a.js → MtcAudit-CVV7LnNS.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-Bjr8O-wy.js → Multisig-BqqGrjt_.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-CEe-KirU.js → NLProgramming-C4WP8DRp.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-spKJIKF9.js → Notes-Zo4DJUVI.js} +4 -4
- package/src/assets/web-panel/assets/{NotificationSettings-DRp83LFG.js → NotificationSettings-DBcjRaz2.js} +1 -1
- package/src/assets/web-panel/assets/OrderTableRenderer-BCQel9bD.js +1 -0
- package/src/assets/web-panel/assets/{Organization-BlgB0IYg.js → Organization-BT9WPBOX.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-a_35HqZI.js → Overflow-D6b4bZ3q.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-CSNHCNPC.js → P2P-Cw3OsYx-.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-C8M1JH-1.js → PdhVaultBrowser-C0nyiK2b.js} +5 -5
- package/src/assets/web-panel/assets/{Permissions-DmjJi9Ms.js → Permissions-zVBZEB_C.js} +3 -3
- package/src/assets/web-panel/assets/{PersonalDataHub-DfuwRyf4.js → PersonalDataHub-BOspsFew.js} +2 -2
- package/src/assets/web-panel/assets/{Pipeline-B2-S4ekE.js → Pipeline-CQWfZ4XH.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-B1dOiBLC.js → Privacy-C2oGJ_WL.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-_pNmc-DX.js → ProjectInit-BZMR4qY1.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-DmwG2Ii1.js → ProjectSettings-DAujoP63.js} +2 -2
- package/src/assets/web-panel/assets/{Projects-COATmHxF.js → Projects-DvH_P2Uq.js} +1 -1
- package/src/assets/web-panel/assets/{Providers-C6HSjg1d.js → Providers-D_RGHM-v.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-CrDmS7l1.js → QrScannerModal-Mf6KfL2J.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-CfKvWt-x.js → QuickAsk-5D8URoI_.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-DMa6Kuzv.js → Recommend-Df4kC8Gy.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-DrktUo5x.js → RemoteSession-DGi3f3vO.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-BTPQ1vjL.js → Reputation-jmkGFNWb.js} +1 -1
- package/src/assets/web-panel/assets/{Row-B6Q7QkMG.js → Row-DP6Pcu3m.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-B1IJhAmX.js → RssFeed-D60hSuPO.js} +3 -3
- package/src/assets/web-panel/assets/{Search-xbea1kGD.js → Search-0mf1BQUK.js} +1 -1
- package/src/assets/web-panel/assets/{Security-7rs0cO3F.js → Security-CfZtLFYV.js} +4 -4
- package/src/assets/web-panel/assets/{Services-0fjixK0S.js → Services-D9zpZLWz.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-B_rVq9Cu.js → Skeleton-D1_44eMD.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-C5uM90b9.js → Skills-cVFa8zkz.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-U4pYCIZH.js → Sla-Cinpzc3L.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-Cdz4yTWx.js → SpeechSettings-bqdiJE_P.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-Me3aetHv.js → SyncSettings-Dgl_SLgj.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-Cf6aNkL_.js → Tasks-DawWAEhy.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-CYatk4tU.js → Templates-Ca0nY651.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-DIMKLw7o.js → Tenant-CgaGEhLm.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-C_cp-uSJ.js → Terminal-D-9EnpRx.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-cUPVNADp.js → TimelineRenderer-CSOxDHZ6.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-CiaEIXVf.js → Tokens-BpwLZpL3.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-DB1WU5lQ.js → Trigger-DgowwBK5.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-Cfht9Ijy.js → Trust-BZutapjE.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-K3P4Vt9S.js → UkeySign-QZPt8_Sp.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-DWjhjXRh.js → VideoEditing-DlQx5UDJ.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-D9-Q1Kan.js → Wallet-ZfdTgtWA.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-BDszy-oV.js → WebAuthn-CAp0y_Cr.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-DIhij4rA.js → WorkflowEditor-BVITZEmr.js} +1 -1
- package/src/assets/web-panel/assets/{chat-D7VCiKbh.js → chat-CC2tPaNg.js} +1 -1
- package/src/assets/web-panel/assets/{colors-BvOd9pVn.js → colors-31brc9CA.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-DoqXBiAO.js → compact-item-DLMkC9C-.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CrKRzUtp.js → createContext-BMtveI6F.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-CB8pPOtd.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-BWAev4WC.js → hasIn-DbD6WHW2.js} +1 -1
- package/src/assets/web-panel/assets/{index-B30NvR21.js → index-3oBsXZmF.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cx7M7PSF.js → index-ALvoNTRF.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHOcVYrx.js → index-AaIKG60c.js} +1 -1
- package/src/assets/web-panel/assets/{index-BXyl8O23.js → index-BFRLmCR3.js} +1 -1
- package/src/assets/web-panel/assets/{index-YGDy7nNq.js → index-BKAB6-6V.js} +1 -1
- package/src/assets/web-panel/assets/{index-BoYrS_L2.js → index-BOM-VFlv.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bq_ctRpz.js → index-BPpS-lGb.js} +1 -1
- package/src/assets/web-panel/assets/{index-CDGLdn12.js → index-Bbc2JeQG.js} +1 -1
- package/src/assets/web-panel/assets/index-BhXiaiev.js +1 -0
- package/src/assets/web-panel/assets/{index-ISvPZ05r.js → index-BiRQUoyE.js} +1 -1
- package/src/assets/web-panel/assets/{index-B0vGEbJT.js → index-BnFLzejT.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bj7EgmOp.js → index-BoO4WEJB.js} +1 -1
- package/src/assets/web-panel/assets/{index-BZGk8AbZ.js → index-C-cGlCDW.js} +1 -1
- package/src/assets/web-panel/assets/{index-BkgnGX7v.js → index-C1PfeI88.js} +1 -1
- package/src/assets/web-panel/assets/{index-HwHdTusY.js → index-C21pdBqs.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bf9YlbDn.js → index-C7LHNw_N.js} +1 -1
- package/src/assets/web-panel/assets/{index-oY45XrkG.js → index-CEXa-4zd.js} +1 -1
- package/src/assets/web-panel/assets/{index-KR1-Em9C.js → index-CEwjBA9q.js} +1 -1
- package/src/assets/web-panel/assets/{index-C5kRmj3P.js → index-CFXKbOek.js} +1 -1
- package/src/assets/web-panel/assets/{index-BN9m4Ux2.js → index-CJhREOlG.js} +1 -1
- package/src/assets/web-panel/assets/{index-PDxGjKRu.js → index-COHHQ9cU.js} +1 -1
- package/src/assets/web-panel/assets/{index-D23L1V76.js → index-CUmLMFsy.js} +1 -1
- package/src/assets/web-panel/assets/{index-zH-tuuGZ.js → index-CWgfipcC.js} +1 -1
- package/src/assets/web-panel/assets/{index-CmJ6xKBO.js → index-C_eUKGYP.js} +1 -1
- package/src/assets/web-panel/assets/{index-CEPHMSih.js → index-CcVszn2b.js} +1 -1
- package/src/assets/web-panel/assets/{index-DV-Rfmfx.js → index-Cod97Urs.js} +3 -3
- package/src/assets/web-panel/assets/{index-BWWsaimP.js → index-Cwbqh0KZ.js} +1 -1
- package/src/assets/web-panel/assets/{index-8g5TJCZ3.js → index-D67kIagl.js} +1 -1
- package/src/assets/web-panel/assets/{index-CEXfKSiI.js → index-D7cV0tu0.js} +1 -1
- package/src/assets/web-panel/assets/{index-CoD31Ndm.js → index-DJvxFyIz.js} +1 -1
- package/src/assets/web-panel/assets/{index-CSb-tnIo.js → index-DZl-UXB6.js} +1 -1
- package/src/assets/web-panel/assets/{index-DvDOpsJB.js → index-Dm0byjm1.js} +1 -1
- package/src/assets/web-panel/assets/{index-DlGu7As6.js → index-DndNnYc0.js} +1 -1
- package/src/assets/web-panel/assets/{index-gmxqu5Kk.js → index-WQUO2vvK.js} +1 -1
- package/src/assets/web-panel/assets/index-WpaYaE3D.js +1 -0
- package/src/assets/web-panel/assets/{index-ww1QtLnX.js → index-bdaJwTXJ.js} +1 -1
- package/src/assets/web-panel/assets/{index-JzUmJMvn.js → index-ep6JchNG.js} +1 -1
- package/src/assets/web-panel/assets/{index-BCyP071m.js → index-jG6h22Qg.js} +1 -1
- package/src/assets/web-panel/assets/{index-1poMpMU0.js → index-ntVQqSML.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-XW-OR3mL.js → initDefaultProps-D5UoBRhN.js} +1 -1
- package/src/assets/web-panel/assets/{motion-CeuEHh20.js → motion-DltOw0CR.js} +1 -1
- package/src/assets/web-panel/assets/{move-Dva79Keg.js → move-_oPkk5zj.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-C0B6bD2j.js → mtc-parser-EES8ROCS.js} +1 -1
- package/src/assets/web-panel/assets/{omit-AkZvO-a2.js → omit-B-_39x_M.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-B4r55qGc.js → pickAttrs-l6D0yJHz.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BLH-z7YE.js → placementArrow-DIeUK31L.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-ksEGZ2Wf.js → responsiveObserve-dG4EzUQo.js} +1 -1
- package/src/assets/web-panel/assets/{slide-Cth4OD1Z.js → slide-DWFzYGdH.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-9S83ezxw.js → statusUtils-CECdTA5H.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-7yQmQGZ4.js → styleChecker-D6kP-mMK.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-DpGnMmPc.js → useFlexGapSupport-BWfna_rd.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-C4zDZIXH.js → useFs-Bb-j6rzo.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-CoLICdUZ.js → usePersonalDataHub-D5OIsYM4.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-CKyH-35X.js → vnode-DNpwzFTM.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-DRDGit17.js → zoom-vNLpFQBW.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/command-manifest.json +17 -3
- package/src/commands/agent.js +7 -0
- package/src/commands/background-session.js +143 -3
- package/src/commands/browse.js +86 -0
- package/src/commands/cloud.js +232 -0
- package/src/commands/doctor.js +61 -6
- package/src/commands/mcp.js +61 -0
- package/src/commands/plugin.js +63 -1
- package/src/commands/routine.js +367 -0
- package/src/commands/session.js +326 -0
- package/src/data/changelog.json +7 -2
- package/src/harness/jsonl-session-store.js +82 -2
- package/src/harness/session-index.js +295 -0
- package/src/harness/session-mirror.js +142 -0
- package/src/harness/transcript-integrity.js +182 -0
- package/src/index.js +8 -0
- package/src/lib/background-agent-supervisor.js +126 -2
- package/src/lib/channels/channel-manager.js +126 -0
- package/src/lib/channels/telegram-channel.js +112 -0
- package/src/lib/channels/webhook-channel.js +140 -0
- package/src/lib/chrome-connector.js +386 -0
- package/src/lib/cloud/bundle.js +110 -0
- package/src/lib/cloud/cloud-client.js +152 -0
- package/src/lib/computer-use/authorization.js +173 -0
- package/src/lib/computer-use/computer-use-server.js +276 -0
- package/src/lib/computer-use/control-backend.js +150 -0
- package/src/lib/doctor-checkup.js +652 -0
- package/src/lib/jsonl-session-store.js +4 -0
- package/src/lib/lsp/lsp-server-registry.js +48 -21
- package/src/lib/plugin-runtime/governance.js +174 -0
- package/src/lib/pr-link-ledger.js +221 -0
- package/src/lib/routine-store.js +352 -0
- package/src/lib/session-store-guard.js +40 -0
- package/src/lib/session-usage.js +318 -2
- package/src/lib/sub-agent-context.js +23 -0
- package/src/repl/agent-repl.js +361 -3
- package/src/repl/bg-dashboard.js +355 -0
- package/src/repl/fast-mode.js +103 -0
- package/src/repl/fullscreen-tui.js +197 -0
- package/src/repl/slash-command-registry.js +11 -3
- package/src/repl/voice-dictation.js +132 -0
- package/src/runtime/agent-core.js +242 -1
- package/src/runtime/coding-agent-contract-shared.cjs +88 -0
- package/src/runtime/coding-agent-policy.cjs +17 -0
- package/src/runtime/headless-runner.js +71 -7
- package/src/runtime/headless-stream.js +14 -8
- package/src/runtime/policies/agent-policy.js +3 -0
- package/src/assets/web-panel/assets/Artifacts-DQvJoQ6E.js +0 -1
- package/src/assets/web-panel/assets/OrderTableRenderer-h1A0XB2a.js +0 -1
- package/src/assets/web-panel/assets/devWarning-nDB--2xr.js +0 -1
- package/src/assets/web-panel/assets/index-B1_pRTtB.js +0 -1
- package/src/assets/web-panel/assets/index-TMM9FuW-.js +0 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session index (gap-2026-07-11 P2#14) — a SQLite index over the append-only
|
|
3
|
+
* JSONL session store.
|
|
4
|
+
*
|
|
5
|
+
* The JSONL files stay the source of truth (append-only, hash-chained,
|
|
6
|
+
* tamper-evident — see jsonl-session-store.js + transcript-integrity.js). But
|
|
7
|
+
* listing/searching them means reading and parsing every file on every call,
|
|
8
|
+
* which does not scale past a few hundred sessions. This index gives O(1) list
|
|
9
|
+
* ordering and indexed content search, and stays in sync incrementally by
|
|
10
|
+
* file mtime+size so a resume/fork/search/verify is cheap.
|
|
11
|
+
*
|
|
12
|
+
* The index is a derived cache: it can be deleted and fully rebuilt from the
|
|
13
|
+
* JSONL at any time (`syncIndex({ force: true })`). Nothing authoritative lives
|
|
14
|
+
* only here.
|
|
15
|
+
*
|
|
16
|
+
* `Database` is injected via `_deps` so tests run against an in-memory SQLite
|
|
17
|
+
* without touching the user's home dir.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
mkdirSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
statSync,
|
|
25
|
+
readFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { join, basename } from "node:path";
|
|
28
|
+
import { createRequire } from "node:module";
|
|
29
|
+
import { getHomeDir } from "../lib/paths.js";
|
|
30
|
+
import { readEvents, toIsoSafe } from "./jsonl-session-store.js";
|
|
31
|
+
import { latestChainHash } from "./transcript-integrity.js";
|
|
32
|
+
|
|
33
|
+
const requireCjs = createRequire(import.meta.url);
|
|
34
|
+
|
|
35
|
+
// Lazy so importing this module never forces the native addon to load (matters
|
|
36
|
+
// for fast CLI paths and for environments where better-sqlite3 is unavailable).
|
|
37
|
+
export const _deps = {
|
|
38
|
+
Database: null,
|
|
39
|
+
loadDatabase() {
|
|
40
|
+
if (!_deps.Database) {
|
|
41
|
+
_deps.Database = requireCjs("better-sqlite3");
|
|
42
|
+
}
|
|
43
|
+
return _deps.Database;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function sessionsDir() {
|
|
48
|
+
return join(getHomeDir(), "sessions");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function indexPath() {
|
|
52
|
+
const dir = getHomeDir();
|
|
53
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
54
|
+
return join(dir, "session-index.db");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SCHEMA = `
|
|
58
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
title TEXT,
|
|
61
|
+
provider TEXT,
|
|
62
|
+
model TEXT,
|
|
63
|
+
message_count INTEGER,
|
|
64
|
+
event_count INTEGER,
|
|
65
|
+
created_at TEXT,
|
|
66
|
+
updated_at TEXT,
|
|
67
|
+
last_ts INTEGER,
|
|
68
|
+
chain_hash TEXT,
|
|
69
|
+
mtime_ms INTEGER,
|
|
70
|
+
size_bytes INTEGER
|
|
71
|
+
);
|
|
72
|
+
CREATE TABLE IF NOT EXISTS session_content (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
text TEXT
|
|
75
|
+
);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_last_ts ON sessions(last_ts DESC);
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Open (and migrate) the index database. Caller owns closing it. Pass
|
|
81
|
+
* `{ file: ":memory:" }` for tests.
|
|
82
|
+
*/
|
|
83
|
+
export function openIndex({ file } = {}) {
|
|
84
|
+
const Database = _deps.loadDatabase();
|
|
85
|
+
const db = new Database(file || indexPath());
|
|
86
|
+
db.pragma("journal_mode = WAL");
|
|
87
|
+
db.exec(SCHEMA);
|
|
88
|
+
return db;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Max characters of concatenated message content indexed per session. */
|
|
92
|
+
const CONTENT_CAP = 200_000;
|
|
93
|
+
|
|
94
|
+
function summarizeEvents(id, events) {
|
|
95
|
+
const startEvent = events.find((e) => e.type === "session_start");
|
|
96
|
+
const lastEvent = events[events.length - 1];
|
|
97
|
+
const messageCount = events.filter(
|
|
98
|
+
(e) => e.type === "user_message" || e.type === "assistant_message",
|
|
99
|
+
).length;
|
|
100
|
+
const parts = [];
|
|
101
|
+
let used = 0;
|
|
102
|
+
for (const e of events) {
|
|
103
|
+
if (e.type !== "user_message" && e.type !== "assistant_message") continue;
|
|
104
|
+
const c = e.data?.content;
|
|
105
|
+
const s = typeof c === "string" ? c : c == null ? "" : JSON.stringify(c);
|
|
106
|
+
if (!s) continue;
|
|
107
|
+
parts.push(s);
|
|
108
|
+
used += s.length;
|
|
109
|
+
if (used >= CONTENT_CAP) break;
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
title: startEvent?.data?.title || "Untitled",
|
|
114
|
+
provider: startEvent?.data?.provider || "",
|
|
115
|
+
model: startEvent?.data?.model || "",
|
|
116
|
+
message_count: messageCount,
|
|
117
|
+
event_count: events.length,
|
|
118
|
+
created_at: toIsoSafe(startEvent?.timestamp),
|
|
119
|
+
updated_at: toIsoSafe(lastEvent?.timestamp),
|
|
120
|
+
last_ts: lastEvent?.timestamp || 0,
|
|
121
|
+
content: parts.join("\n").slice(0, CONTENT_CAP),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const UPSERT_SESSION = `
|
|
126
|
+
INSERT INTO sessions
|
|
127
|
+
(id,title,provider,model,message_count,event_count,created_at,updated_at,last_ts,chain_hash,mtime_ms,size_bytes)
|
|
128
|
+
VALUES (@id,@title,@provider,@model,@message_count,@event_count,@created_at,@updated_at,@last_ts,@chain_hash,@mtime_ms,@size_bytes)
|
|
129
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
130
|
+
title=@title, provider=@provider, model=@model, message_count=@message_count,
|
|
131
|
+
event_count=@event_count, created_at=@created_at, updated_at=@updated_at,
|
|
132
|
+
last_ts=@last_ts, chain_hash=@chain_hash, mtime_ms=@mtime_ms, size_bytes=@size_bytes
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Index (or re-index) a single session from its JSONL into `db`.
|
|
137
|
+
* @returns {boolean} true if a row was written.
|
|
138
|
+
*/
|
|
139
|
+
export function indexOneSession(db, id, { mtimeMs = 0, sizeBytes = 0 } = {}) {
|
|
140
|
+
const events = readEvents(id);
|
|
141
|
+
if (events.length === 0) return false;
|
|
142
|
+
const s = summarizeEvents(id, events);
|
|
143
|
+
const filePath = join(sessionsDir(), `${id}.jsonl`);
|
|
144
|
+
let chainHash = "";
|
|
145
|
+
try {
|
|
146
|
+
chainHash = latestChainHash(readFileSync(filePath, "utf-8")) || "";
|
|
147
|
+
} catch {
|
|
148
|
+
/* chain hash is best-effort */
|
|
149
|
+
}
|
|
150
|
+
db.prepare(UPSERT_SESSION).run({
|
|
151
|
+
id: s.id,
|
|
152
|
+
title: s.title,
|
|
153
|
+
provider: s.provider,
|
|
154
|
+
model: s.model,
|
|
155
|
+
message_count: s.message_count,
|
|
156
|
+
event_count: s.event_count,
|
|
157
|
+
created_at: s.created_at,
|
|
158
|
+
updated_at: s.updated_at,
|
|
159
|
+
last_ts: s.last_ts,
|
|
160
|
+
chain_hash: chainHash,
|
|
161
|
+
mtime_ms: Math.floor(mtimeMs),
|
|
162
|
+
size_bytes: sizeBytes,
|
|
163
|
+
});
|
|
164
|
+
db.prepare(
|
|
165
|
+
"INSERT INTO session_content (id,text) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET text=excluded.text",
|
|
166
|
+
).run(s.id, s.content);
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Incrementally sync the index with the sessions dir. A session is re-indexed
|
|
172
|
+
* only when its file mtime or size changed since last time (or `force`).
|
|
173
|
+
* Deleted JSONL files have their index rows removed.
|
|
174
|
+
* @returns {{ scanned, updated, removed, total }}
|
|
175
|
+
*/
|
|
176
|
+
export function syncIndex(db, { force = false } = {}) {
|
|
177
|
+
const dir = sessionsDir();
|
|
178
|
+
let scanned = 0;
|
|
179
|
+
let updated = 0;
|
|
180
|
+
let removed = 0;
|
|
181
|
+
const seen = new Set();
|
|
182
|
+
|
|
183
|
+
if (existsSync(dir)) {
|
|
184
|
+
const known = new Map(
|
|
185
|
+
db
|
|
186
|
+
.prepare("SELECT id, mtime_ms, size_bytes FROM sessions")
|
|
187
|
+
.all()
|
|
188
|
+
.map((r) => [r.id, r]),
|
|
189
|
+
);
|
|
190
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
|
|
191
|
+
const tx = db.transaction((entries) => {
|
|
192
|
+
for (const { id, mtimeMs, size } of entries) {
|
|
193
|
+
seen.add(id);
|
|
194
|
+
scanned++;
|
|
195
|
+
const prev = known.get(id);
|
|
196
|
+
if (
|
|
197
|
+
!force &&
|
|
198
|
+
prev &&
|
|
199
|
+
prev.mtime_ms === Math.floor(mtimeMs) &&
|
|
200
|
+
prev.size_bytes === size
|
|
201
|
+
) {
|
|
202
|
+
continue; // unchanged
|
|
203
|
+
}
|
|
204
|
+
if (indexOneSession(db, id, { mtimeMs, sizeBytes: size })) updated++;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
const entries = [];
|
|
208
|
+
for (const f of files) {
|
|
209
|
+
const id = basename(f, ".jsonl");
|
|
210
|
+
let st;
|
|
211
|
+
try {
|
|
212
|
+
st = statSync(join(dir, f));
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
entries.push({ id, mtimeMs: st.mtimeMs, size: st.size });
|
|
217
|
+
}
|
|
218
|
+
tx(entries);
|
|
219
|
+
|
|
220
|
+
// Drop rows whose JSONL no longer exists.
|
|
221
|
+
for (const id of known.keys()) {
|
|
222
|
+
if (!seen.has(id)) {
|
|
223
|
+
db.prepare("DELETE FROM sessions WHERE id=?").run(id);
|
|
224
|
+
db.prepare("DELETE FROM session_content WHERE id=?").run(id);
|
|
225
|
+
removed++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const total = db.prepare("SELECT COUNT(*) c FROM sessions").get().c;
|
|
231
|
+
return { scanned, updated, removed, total };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const LIST_COLUMNS =
|
|
235
|
+
"id,title,provider,model,message_count,event_count,created_at,updated_at";
|
|
236
|
+
|
|
237
|
+
/** List indexed sessions, newest activity first. */
|
|
238
|
+
export function listSessions(db, { limit = 20 } = {}) {
|
|
239
|
+
return db
|
|
240
|
+
.prepare(
|
|
241
|
+
`SELECT ${LIST_COLUMNS} FROM sessions ORDER BY last_ts DESC, event_count DESC LIMIT ?`,
|
|
242
|
+
)
|
|
243
|
+
.all(Math.max(1, limit | 0));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Full-text-ish search over session titles and message content (LIKE). Returns
|
|
248
|
+
* matched sessions with a short snippet around the first hit.
|
|
249
|
+
*/
|
|
250
|
+
export function searchSessions(db, query, { limit = 20 } = {}) {
|
|
251
|
+
const q = String(query || "").trim();
|
|
252
|
+
if (!q) return [];
|
|
253
|
+
const like = `%${q.replace(/[%_]/g, (m) => "\\" + m)}%`;
|
|
254
|
+
const rows = db
|
|
255
|
+
.prepare(
|
|
256
|
+
`SELECT s.${LIST_COLUMNS.split(",").join(", s.")}, c.text AS _text
|
|
257
|
+
FROM sessions s LEFT JOIN session_content c ON c.id = s.id
|
|
258
|
+
WHERE s.title LIKE ? ESCAPE '\\' OR c.text LIKE ? ESCAPE '\\'
|
|
259
|
+
ORDER BY s.last_ts DESC LIMIT ?`,
|
|
260
|
+
)
|
|
261
|
+
.all(like, like, Math.max(1, limit | 0));
|
|
262
|
+
const needle = q.toLowerCase();
|
|
263
|
+
return rows.map((r) => {
|
|
264
|
+
const text = r._text || "";
|
|
265
|
+
const at = text.toLowerCase().indexOf(needle);
|
|
266
|
+
const snippet =
|
|
267
|
+
at >= 0
|
|
268
|
+
? text
|
|
269
|
+
.slice(Math.max(0, at - 40), at + q.length + 40)
|
|
270
|
+
.replace(/\s+/g, " ")
|
|
271
|
+
.trim()
|
|
272
|
+
: "";
|
|
273
|
+
const { _text, ...rest } = r;
|
|
274
|
+
return { ...rest, snippet };
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One indexed session row, or null. */
|
|
279
|
+
export function getIndexedSession(db, id) {
|
|
280
|
+
return db.prepare(`SELECT * FROM sessions WHERE id=?`).get(id) || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Index-wide counters for `cc doctor` / `cc session index --stats`. */
|
|
284
|
+
export function indexStats(db) {
|
|
285
|
+
const row = db
|
|
286
|
+
.prepare(
|
|
287
|
+
"SELECT COUNT(*) sessions, COALESCE(SUM(message_count),0) messages, COALESCE(SUM(event_count),0) events FROM sessions",
|
|
288
|
+
)
|
|
289
|
+
.get();
|
|
290
|
+
return {
|
|
291
|
+
sessions: row.sessions,
|
|
292
|
+
messages: row.messages,
|
|
293
|
+
events: row.events,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session mirror (gap-2026-07-11 P2#14) — a pluggable off-box copy of the JSONL
|
|
3
|
+
* session store for cross-device / cloud-runner / team scenarios.
|
|
4
|
+
*
|
|
5
|
+
* The mirror is one-way derived from the local JSONL (which stays the source of
|
|
6
|
+
* truth): `push(id)` copies a session's bytes out, `pull(id)` fetches them back,
|
|
7
|
+
* `list()` enumerates what the remote holds. Encryption and access control are
|
|
8
|
+
* the driver's concern (configured independently), so a driver may wrap bytes
|
|
9
|
+
* before they leave the box.
|
|
10
|
+
*
|
|
11
|
+
* Drivers implement a tiny interface `{ push(id, bytes), pull(id), list() }`.
|
|
12
|
+
* Two are built in:
|
|
13
|
+
* - `fs` : mirror to a local/mounted directory (reference impl; also the
|
|
14
|
+
* test target). Covers NFS/SMB-mounted shares for free.
|
|
15
|
+
* - `http` : PUT/GET/list against a self-hosted API mirror (injectable fetch).
|
|
16
|
+
* S3 / WebDAV / Redis are additional drivers that plug in behind the same
|
|
17
|
+
* factory; they need real endpoints so they are configured, not bundled.
|
|
18
|
+
*
|
|
19
|
+
* Pure/DI: filesystem and fetch go through injected deps so the factory and the
|
|
20
|
+
* fs driver are unit-testable without network or a real home dir.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
writeFileSync,
|
|
28
|
+
readdirSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { join, basename } from "node:path";
|
|
31
|
+
import { isUnsafeSessionId } from "./jsonl-session-store.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a mirror driver from config. `config.kind` selects the driver:
|
|
35
|
+
* { kind: "fs", dir: "/mnt/backup/sessions" }
|
|
36
|
+
* { kind: "http", baseUrl: "https://mirror.local", token: "…" }
|
|
37
|
+
* Returns null when no mirror is configured (kind falsy / "none").
|
|
38
|
+
*/
|
|
39
|
+
export function createMirror(config = {}, deps = {}) {
|
|
40
|
+
const kind = String(config.kind || config.type || "none").toLowerCase();
|
|
41
|
+
if (!kind || kind === "none") return null;
|
|
42
|
+
if (kind === "fs" || kind === "dir" || kind === "directory") {
|
|
43
|
+
return createFsMirror(config, deps);
|
|
44
|
+
}
|
|
45
|
+
if (kind === "http" || kind === "api") {
|
|
46
|
+
return createHttpMirror(config, deps);
|
|
47
|
+
}
|
|
48
|
+
throw new Error(
|
|
49
|
+
`unknown session mirror kind "${kind}" (use fs | http, or wire an s3/webdav/redis driver)`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Directory mirror — the reference driver. */
|
|
54
|
+
export function createFsMirror(config = {}, deps = {}) {
|
|
55
|
+
const dir = config.dir || config.path;
|
|
56
|
+
if (!dir) throw new Error("fs mirror requires a `dir`");
|
|
57
|
+
const _existsSync = deps.existsSync || existsSync;
|
|
58
|
+
const _mkdirSync = deps.mkdirSync || mkdirSync;
|
|
59
|
+
const _readFileSync = deps.readFileSync || readFileSync;
|
|
60
|
+
const _writeFileSync = deps.writeFileSync || writeFileSync;
|
|
61
|
+
const _readdirSync = deps.readdirSync || readdirSync;
|
|
62
|
+
|
|
63
|
+
const ensure = () => {
|
|
64
|
+
if (!_existsSync(dir)) _mkdirSync(dir, { recursive: true });
|
|
65
|
+
return dir;
|
|
66
|
+
};
|
|
67
|
+
const fileFor = (id) => {
|
|
68
|
+
if (isUnsafeSessionId(id)) throw new Error(`unsafe session id: ${id}`);
|
|
69
|
+
return join(dir, `${id}.jsonl`);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
kind: "fs",
|
|
74
|
+
target: dir,
|
|
75
|
+
async push(id, bytes) {
|
|
76
|
+
ensure();
|
|
77
|
+
_writeFileSync(fileFor(id), bytes, "utf-8");
|
|
78
|
+
return { id, bytes: Buffer.byteLength(bytes) };
|
|
79
|
+
},
|
|
80
|
+
async pull(id) {
|
|
81
|
+
const f = fileFor(id);
|
|
82
|
+
if (!_existsSync(f)) return null;
|
|
83
|
+
return _readFileSync(f, "utf-8");
|
|
84
|
+
},
|
|
85
|
+
async list() {
|
|
86
|
+
if (!_existsSync(dir)) return [];
|
|
87
|
+
return _readdirSync(dir)
|
|
88
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
89
|
+
.map((f) => basename(f, ".jsonl"));
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** HTTP mirror — PUT/GET/list against a self-hosted API. `fetch` injectable. */
|
|
95
|
+
export function createHttpMirror(config = {}, deps = {}) {
|
|
96
|
+
const baseUrl = String(config.baseUrl || config.url || "").replace(
|
|
97
|
+
/\/+$/,
|
|
98
|
+
"",
|
|
99
|
+
);
|
|
100
|
+
if (!baseUrl) throw new Error("http mirror requires a `baseUrl`");
|
|
101
|
+
const token = config.token || null;
|
|
102
|
+
const doFetch = deps.fetch || ((...a) => globalThis.fetch(...a));
|
|
103
|
+
const headers = (extra = {}) => ({
|
|
104
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
105
|
+
...extra,
|
|
106
|
+
});
|
|
107
|
+
const guard = (id) => {
|
|
108
|
+
if (isUnsafeSessionId(id)) throw new Error(`unsafe session id: ${id}`);
|
|
109
|
+
return encodeURIComponent(id);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
kind: "http",
|
|
114
|
+
target: baseUrl,
|
|
115
|
+
async push(id, bytes) {
|
|
116
|
+
const res = await doFetch(`${baseUrl}/sessions/${guard(id)}`, {
|
|
117
|
+
method: "PUT",
|
|
118
|
+
headers: headers({ "Content-Type": "application/x-ndjson" }),
|
|
119
|
+
body: bytes,
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) throw new Error(`mirror PUT ${id} → HTTP ${res.status}`);
|
|
122
|
+
return { id, bytes: Buffer.byteLength(bytes) };
|
|
123
|
+
},
|
|
124
|
+
async pull(id) {
|
|
125
|
+
const res = await doFetch(`${baseUrl}/sessions/${guard(id)}`, {
|
|
126
|
+
headers: headers(),
|
|
127
|
+
});
|
|
128
|
+
if (res.status === 404) return null;
|
|
129
|
+
if (!res.ok) throw new Error(`mirror GET ${id} → HTTP ${res.status}`);
|
|
130
|
+
return res.text();
|
|
131
|
+
},
|
|
132
|
+
async list() {
|
|
133
|
+
const res = await doFetch(`${baseUrl}/sessions`, { headers: headers() });
|
|
134
|
+
if (!res.ok) throw new Error(`mirror list → HTTP ${res.status}`);
|
|
135
|
+
const data = await res.json();
|
|
136
|
+
const ids = Array.isArray(data) ? data : data?.sessions || [];
|
|
137
|
+
return ids
|
|
138
|
+
.map((x) => (typeof x === "string" ? x : x?.id))
|
|
139
|
+
.filter(Boolean);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript integrity — hash-chained JSONL session transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Every event appended to a session transcript carries `prevHash` (the hash of
|
|
5
|
+
* the previous event, or null for the first chained event) and `hash` — a
|
|
6
|
+
* SHA-256 over the previous hash plus the event's canonical core
|
|
7
|
+
* (`{type, timestamp, data}`). Editing, deleting, inserting, or reordering any
|
|
8
|
+
* chained line breaks the chain and is detected by `verifyTranscriptLines`.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints:
|
|
11
|
+
* - Pure module: no fs, no paths — the store layer (jsonl-session-store.js)
|
|
12
|
+
* owns file IO so this stays cycle-free and unit-testable in isolation.
|
|
13
|
+
* - Backward compatible: transcripts written before chaining have no hash
|
|
14
|
+
* fields. A fully legacy file verifies as `legacy`; a legacy prefix followed
|
|
15
|
+
* by a valid chain is `partial` (chaining starts on the first upgraded
|
|
16
|
+
* append). An UNchained record appearing AFTER chained records is flagged as
|
|
17
|
+
* tampered — our writer never does that, so it is either a downgrade-write
|
|
18
|
+
* or a manual append, both of which the verifier must surface.
|
|
19
|
+
* - Honest limitation: truncating the file tail (deleting the newest N chained
|
|
20
|
+
* lines) is undetectable without an external anchor — the chain is
|
|
21
|
+
* self-contained. A half-written LAST line (crash mid-append) is reported as
|
|
22
|
+
* `truncatedTail: true` rather than tampered.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
|
|
27
|
+
export const TRANSCRIPT_CHAIN_STATUS = Object.freeze({
|
|
28
|
+
VERIFIED: "verified", // every record chained + chain valid
|
|
29
|
+
PARTIAL: "partial", // legacy prefix, then a valid chain
|
|
30
|
+
LEGACY: "legacy", // no chained records at all (pre-chaining transcript)
|
|
31
|
+
TAMPERED: "tampered", // chain broken: edit / delete / insert / reorder
|
|
32
|
+
EMPTY: "empty", // no records
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Hash one event's canonical core against the previous chain hash.
|
|
37
|
+
* The core is rebuilt field-by-field (not the raw line) so the hash is
|
|
38
|
+
* independent of the presence/position of the prevHash/hash fields themselves;
|
|
39
|
+
* JSON.parse → JSON.stringify preserves the writer's key insertion order for
|
|
40
|
+
* `data`, so writer and verifier serialize identically.
|
|
41
|
+
*/
|
|
42
|
+
export function computeEventHash(prevHash, core) {
|
|
43
|
+
const canonical = JSON.stringify({
|
|
44
|
+
type: core.type,
|
|
45
|
+
timestamp: core.timestamp,
|
|
46
|
+
data: core.data,
|
|
47
|
+
});
|
|
48
|
+
return createHash("sha256")
|
|
49
|
+
.update(`${prevHash || "genesis"}\n${canonical}`, "utf-8")
|
|
50
|
+
.digest("hex");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The hash the NEXT appended event must chain from: the `hash` of the last
|
|
55
|
+
* parseable line, or null when the file is empty / fully legacy / the last
|
|
56
|
+
* parseable line is unchained. A trailing half-written line (crash mid-append)
|
|
57
|
+
* is skipped so the writer chains from the last intact record.
|
|
58
|
+
*/
|
|
59
|
+
export function latestChainHash(text) {
|
|
60
|
+
const lines = String(text || "").split("\n");
|
|
61
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
try {
|
|
65
|
+
const event = JSON.parse(line);
|
|
66
|
+
return typeof event?.hash === "string" ? event.hash : null;
|
|
67
|
+
} catch {
|
|
68
|
+
// half-written tail line — chain from the previous intact record
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Verify a transcript's hash chain.
|
|
77
|
+
*
|
|
78
|
+
* @param {string[]} lines raw transcript lines (as split on "\n")
|
|
79
|
+
* @returns {{
|
|
80
|
+
* status: string, // TRANSCRIPT_CHAIN_STATUS value
|
|
81
|
+
* chainedEvents: number,
|
|
82
|
+
* legacyEvents: number,
|
|
83
|
+
* malformedLines: number,
|
|
84
|
+
* truncatedTail: boolean, // last non-empty line is half-written JSON
|
|
85
|
+
* firstInvalidLine: number|null, // 1-based line number of the first break
|
|
86
|
+
* reason: string|null,
|
|
87
|
+
* }}
|
|
88
|
+
*/
|
|
89
|
+
export function verifyTranscriptLines(lines) {
|
|
90
|
+
const input = Array.isArray(lines) ? lines : [];
|
|
91
|
+
let lastNonEmpty = -1;
|
|
92
|
+
for (let i = input.length - 1; i >= 0; i--) {
|
|
93
|
+
if (input[i] && input[i].trim()) {
|
|
94
|
+
lastNonEmpty = i;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = {
|
|
100
|
+
status: TRANSCRIPT_CHAIN_STATUS.EMPTY,
|
|
101
|
+
chainedEvents: 0,
|
|
102
|
+
legacyEvents: 0,
|
|
103
|
+
malformedLines: 0,
|
|
104
|
+
truncatedTail: false,
|
|
105
|
+
firstInvalidLine: null,
|
|
106
|
+
reason: null,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
let lastHash = null;
|
|
110
|
+
let sawChain = false;
|
|
111
|
+
|
|
112
|
+
const tampered = (lineNo, reason) => {
|
|
113
|
+
result.status = TRANSCRIPT_CHAIN_STATUS.TAMPERED;
|
|
114
|
+
result.firstInvalidLine = lineNo;
|
|
115
|
+
result.reason = reason;
|
|
116
|
+
return result;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < input.length; i++) {
|
|
120
|
+
const line = input[i];
|
|
121
|
+
if (!line || !line.trim()) continue;
|
|
122
|
+
const lineNo = i + 1;
|
|
123
|
+
|
|
124
|
+
let event;
|
|
125
|
+
try {
|
|
126
|
+
event = JSON.parse(line);
|
|
127
|
+
} catch {
|
|
128
|
+
result.malformedLines++;
|
|
129
|
+
if (i === lastNonEmpty) {
|
|
130
|
+
// Crash mid-append: the newest line is half-written. Not tampering —
|
|
131
|
+
// report it so callers can surface a warning.
|
|
132
|
+
result.truncatedTail = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (sawChain) {
|
|
136
|
+
return tampered(lineNo, "malformed line inside hash chain");
|
|
137
|
+
}
|
|
138
|
+
continue; // junk inside the legacy region — validate reports it
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (event && typeof event.hash === "string") {
|
|
142
|
+
const expectedPrev = sawChain ? lastHash : null;
|
|
143
|
+
if ((event.prevHash ?? null) !== expectedPrev) {
|
|
144
|
+
return tampered(
|
|
145
|
+
lineNo,
|
|
146
|
+
sawChain
|
|
147
|
+
? "hash chain linkage broken (record deleted, inserted, or reordered)"
|
|
148
|
+
: "chain does not start at a genesis record (head records removed)",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (computeEventHash(expectedPrev, event) !== event.hash) {
|
|
152
|
+
return tampered(lineNo, "event content does not match its hash");
|
|
153
|
+
}
|
|
154
|
+
sawChain = true;
|
|
155
|
+
lastHash = event.hash;
|
|
156
|
+
result.chainedEvents++;
|
|
157
|
+
} else {
|
|
158
|
+
if (sawChain) {
|
|
159
|
+
return tampered(
|
|
160
|
+
lineNo,
|
|
161
|
+
"unchained record after hash chain started (manual append or downgrade write)",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
result.legacyEvents++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (result.chainedEvents > 0) {
|
|
169
|
+
result.status =
|
|
170
|
+
result.legacyEvents > 0
|
|
171
|
+
? TRANSCRIPT_CHAIN_STATUS.PARTIAL
|
|
172
|
+
: TRANSCRIPT_CHAIN_STATUS.VERIFIED;
|
|
173
|
+
} else if (result.legacyEvents > 0 || result.malformedLines > 0) {
|
|
174
|
+
result.status = TRANSCRIPT_CHAIN_STATUS.LEGACY;
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Convenience wrapper for whole-file text. */
|
|
180
|
+
export function verifyTranscriptText(text) {
|
|
181
|
+
return verifyTranscriptLines(String(text || "").split("\n"));
|
|
182
|
+
}
|
package/src/index.js
CHANGED
|
@@ -159,6 +159,12 @@ import { registerRemoteControlCommand } from "./commands/remote-control.js";
|
|
|
159
159
|
// Agenda: consumer for agent-scheduled wakeups/crons/monitors (第四阶段 #3)
|
|
160
160
|
import { registerAgendaCommand } from "./commands/agenda.js";
|
|
161
161
|
|
|
162
|
+
// Routines: named scheduled/triggered agent tasks with run history (gap-2026-07-11 P1#8)
|
|
163
|
+
import { registerRoutineCommand } from "./commands/routine.js";
|
|
164
|
+
|
|
165
|
+
// Cloud: self-hosted cloud handoff — offload a task to a private runner (gap-2026-07-11 P1#7)
|
|
166
|
+
import { registerCloudCommand } from "./commands/cloud.js";
|
|
167
|
+
|
|
162
168
|
// Batch: dynamic worktree batch — split a change into parallel units (第四阶段 #4)
|
|
163
169
|
import { registerBatchCommand } from "./commands/batch.js";
|
|
164
170
|
|
|
@@ -597,6 +603,8 @@ export function createProgram(opts = {}) {
|
|
|
597
603
|
|
|
598
604
|
// Agenda: fire agent-scheduled wakeups/crons/monitors
|
|
599
605
|
registerAgendaCommand(program);
|
|
606
|
+
registerRoutineCommand(program);
|
|
607
|
+
registerCloudCommand(program);
|
|
600
608
|
|
|
601
609
|
// Batch: dynamic worktree batch
|
|
602
610
|
registerBatchCommand(program);
|