chainlesschain 0.162.158 → 0.162.160
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 +1 -1
- package/package.json +1 -1
- package/src/assets/web-panel/assets/{AIOps-BwpTJTN_.js → AIOps-CkRHjW8e.js} +1 -1
- package/src/assets/web-panel/assets/{ActionButton-BtQOoo3l.js → ActionButton-BP3MeNi2.js} +1 -1
- package/src/assets/web-panel/assets/{Analytics-Duf_rXZa.js → Analytics-CjbjQsOr.js} +2 -2
- package/src/assets/web-panel/assets/{AppLayout-XQK3wjvB.js → AppLayout-i0adH1M8.js} +5 -5
- package/src/assets/web-panel/assets/Artifacts-CCCG2GOU.js +1 -0
- package/src/assets/web-panel/assets/{Audit-DNjOGQLh.js → Audit-DrsgvwHC.js} +1 -1
- package/src/assets/web-panel/assets/{BackgroundAgents-FH9RAfB5.js → BackgroundAgents-DkXNkbZM.js} +1 -1
- package/src/assets/web-panel/assets/{Backup-ZIbrR4-U.js → Backup-DarTWCex.js} +1 -1
- package/src/assets/web-panel/assets/{BaseInput-DSt3NaND.js → BaseInput-DaDzhcto.js} +1 -1
- package/src/assets/web-panel/assets/{Chat-CY-wvbjv.js → Chat-C4EzGw6V.js} +5 -5
- package/src/assets/web-panel/assets/ChatBubbleRenderer-lkavIWMS.js +1 -0
- package/src/assets/web-panel/assets/{Checkbox-8JVadE_B.js → Checkbox-gddNgaQi.js} +1 -1
- package/src/assets/web-panel/assets/{Codegen-CN83nPYI.js → Codegen-BKldFWf9.js} +1 -1
- package/src/assets/web-panel/assets/{Col-xPSvqdN8.js → Col-ChR-brDs.js} +1 -1
- package/src/assets/web-panel/assets/{Community-D78cnIjG.js → Community-DlHtZX86.js} +1 -1
- package/src/assets/web-panel/assets/{Compact-CSw6Csbp.js → Compact-BAhfcBpC.js} +1 -1
- package/src/assets/web-panel/assets/{Compliance-E_HhjcYR.js → Compliance-gIrKkm6m.js} +1 -1
- package/src/assets/web-panel/assets/{Cowork-DybeQjmm.js → Cowork-BYnzLBO3.js} +3 -3
- package/src/assets/web-panel/assets/{Cron-DS0BJeLY.js → Cron-ByUDIljI.js} +2 -2
- package/src/assets/web-panel/assets/{Crosschain-BOwVgkbs.js → Crosschain-DOdNbHFp.js} +1 -1
- package/src/assets/web-panel/assets/{DID-DRUr9YuX.js → DID-BJHaNWD2.js} +2 -2
- package/src/assets/web-panel/assets/{Dashboard-Cht-yUKW.js → Dashboard-Drm_tClz.js} +2 -2
- package/src/assets/web-panel/assets/{Dropdown-BLr_EJ9X.js → Dropdown-CA1WLzGN.js} +1 -1
- package/src/assets/web-panel/assets/{EmailListRenderer-Drexuh5W.js → EmailListRenderer-Di1SKjNO.js} +1 -1
- package/src/assets/web-panel/assets/{FamilyGuardDashboard-D9Q2Kq63.js → FamilyGuardDashboard-44Ve7ZFe.js} +1 -1
- package/src/assets/web-panel/assets/{Federation-C-Z1xI-T.js → Federation-CubPwv_N.js} +1 -1
- package/src/assets/web-panel/assets/{FormItemContext-DLHPMkQF.js → FormItemContext-CsUFe5bU.js} +1 -1
- package/src/assets/web-panel/assets/GenericCardRenderer-D7xRjPjZ.js +1 -0
- package/src/assets/web-panel/assets/{Git-syATcaBt.js → Git-D-Qx9dhO.js} +2 -2
- package/src/assets/web-panel/assets/{Governance-CYe_r8Zf.js → Governance-_YMYaU4n.js} +1 -1
- package/src/assets/web-panel/assets/{Inference-GWxErB7k.js → Inference-J7Yzh-K_.js} +1 -1
- package/src/assets/web-panel/assets/{KnowledgeGraph-qQvVCZoi.js → KnowledgeGraph-D37iI_Mo.js} +1 -1
- package/src/assets/web-panel/assets/{Logs-BenAU2W8.js → Logs-oMTSYfGY.js} +2 -2
- package/src/assets/web-panel/assets/{MarkdownRenderer-7agE3Or1.js → MarkdownRenderer-BqaPmC0W.js} +1 -1
- package/src/assets/web-panel/assets/{Marketplace-D4Jn5fpD.js → Marketplace-DKUqYDaF.js} +1 -1
- package/src/assets/web-panel/assets/{McpTools-DtAwoqiQ.js → McpTools-BEv3Gx61.js} +4 -4
- package/src/assets/web-panel/assets/{Memory-BI_O50I8.js → Memory-CtA8XD2y.js} +2 -2
- package/src/assets/web-panel/assets/{MobileBridge-CQWE2lqx.js → MobileBridge-ug7c1mMj.js} +1 -1
- package/src/assets/web-panel/assets/MobileProjects-BpX5g9It.js +1 -0
- package/src/assets/web-panel/assets/{Mtc-Dnr16qsn.js → Mtc-WJaMs4i2.js} +2 -2
- package/src/assets/web-panel/assets/{MtcAudit-CQ5Xe4-a.js → MtcAudit-DXnK_-ru.js} +2 -2
- package/src/assets/web-panel/assets/{Multisig-Bjr8O-wy.js → Multisig-C4Ev7726.js} +3 -3
- package/src/assets/web-panel/assets/{NLProgramming-CEe-KirU.js → NLProgramming-BMFb9_zk.js} +1 -1
- package/src/assets/web-panel/assets/{Notes-spKJIKF9.js → Notes-Bl93nvBX.js} +4 -4
- package/src/assets/web-panel/assets/{NotificationSettings-DRp83LFG.js → NotificationSettings-BN2W5cl6.js} +1 -1
- package/src/assets/web-panel/assets/{OrderTableRenderer-h1A0XB2a.js → OrderTableRenderer-Dxc9oN4o.js} +1 -1
- package/src/assets/web-panel/assets/{Organization-BlgB0IYg.js → Organization-CAs48637.js} +4 -4
- package/src/assets/web-panel/assets/{Overflow-a_35HqZI.js → Overflow-Bhq27Zoc.js} +1 -1
- package/src/assets/web-panel/assets/{P2P-CSNHCNPC.js → P2P-D61-2542.js} +2 -2
- package/src/assets/web-panel/assets/{PdhVaultBrowser-C8M1JH-1.js → PdhVaultBrowser-C1j4iUxz.js} +5 -5
- package/src/assets/web-panel/assets/{Permissions-DmjJi9Ms.js → Permissions-rJ2-fScM.js} +3 -3
- package/src/assets/web-panel/assets/{PersonalDataHub-DfuwRyf4.js → PersonalDataHub-DFIlTok6.js} +2 -2
- package/src/assets/web-panel/assets/{Pipeline-B2-S4ekE.js → Pipeline-BjIgEWoZ.js} +1 -1
- package/src/assets/web-panel/assets/{Privacy-B1dOiBLC.js → Privacy-BQoH5wcX.js} +1 -1
- package/src/assets/web-panel/assets/{ProjectInit-_pNmc-DX.js → ProjectInit-CYR_-_Ub.js} +2 -2
- package/src/assets/web-panel/assets/{ProjectSettings-DmwG2Ii1.js → ProjectSettings-nkatCq-9.js} +2 -2
- package/src/assets/web-panel/assets/Projects-SS9uZKCl.js +1 -0
- package/src/assets/web-panel/assets/{Providers-C6HSjg1d.js → Providers-BnKM9fsF.js} +1 -1
- package/src/assets/web-panel/assets/{QrScannerModal-CrDmS7l1.js → QrScannerModal-Cfe82dbz.js} +1 -1
- package/src/assets/web-panel/assets/{QuickAsk-CfKvWt-x.js → QuickAsk-r1RbPufV.js} +1 -1
- package/src/assets/web-panel/assets/{Recommend-DMa6Kuzv.js → Recommend-CjCbM2WE.js} +1 -1
- package/src/assets/web-panel/assets/{RemoteSession-DrktUo5x.js → RemoteSession-BuMGPH6V.js} +2 -2
- package/src/assets/web-panel/assets/{Reputation-BTPQ1vjL.js → Reputation-Bmj3NY1F.js} +1 -1
- package/src/assets/web-panel/assets/{Row-B6Q7QkMG.js → Row-BIsNcE_1.js} +1 -1
- package/src/assets/web-panel/assets/{RssFeed-B1IJhAmX.js → RssFeed-BObmojD2.js} +3 -3
- package/src/assets/web-panel/assets/{Search-xbea1kGD.js → Search-BRmbkQNS.js} +1 -1
- package/src/assets/web-panel/assets/{Security-7rs0cO3F.js → Security-_rF3DRpY.js} +2 -2
- package/src/assets/web-panel/assets/{Services-0fjixK0S.js → Services-D0If9ZV4.js} +2 -2
- package/src/assets/web-panel/assets/{Skeleton-B_rVq9Cu.js → Skeleton-o7zIksUg.js} +1 -1
- package/src/assets/web-panel/assets/{Skills-C5uM90b9.js → Skills-oUR7zi9u.js} +1 -1
- package/src/assets/web-panel/assets/{Sla-U4pYCIZH.js → Sla-fqKyGi03.js} +1 -1
- package/src/assets/web-panel/assets/{SpeechSettings-Cdz4yTWx.js → SpeechSettings-B1wZ_gxx.js} +1 -1
- package/src/assets/web-panel/assets/{SyncSettings-Me3aetHv.js → SyncSettings-B3qJWYXq.js} +2 -2
- package/src/assets/web-panel/assets/{Tasks-Cf6aNkL_.js → Tasks-BH5_dUfr.js} +1 -1
- package/src/assets/web-panel/assets/{Templates-CYatk4tU.js → Templates-CCIVpjRP.js} +1 -1
- package/src/assets/web-panel/assets/{Tenant-DIMKLw7o.js → Tenant-2GZtTGnB.js} +1 -1
- package/src/assets/web-panel/assets/{Terminal-C_cp-uSJ.js → Terminal-BlMkfs_u.js} +2 -2
- package/src/assets/web-panel/assets/{TimelineRenderer-cUPVNADp.js → TimelineRenderer-U6hi5OMc.js} +1 -1
- package/src/assets/web-panel/assets/{Tokens-CiaEIXVf.js → Tokens-C-_BerGJ.js} +1 -1
- package/src/assets/web-panel/assets/{Trigger-DB1WU5lQ.js → Trigger-DaKzBgN3.js} +1 -1
- package/src/assets/web-panel/assets/{Trust-Cfht9Ijy.js → Trust-CLvcsco3.js} +1 -1
- package/src/assets/web-panel/assets/{UkeySign-K3P4Vt9S.js → UkeySign-BFn_Y1zZ.js} +1 -1
- package/src/assets/web-panel/assets/{VideoEditing-DWjhjXRh.js → VideoEditing-B0V18dLT.js} +1 -1
- package/src/assets/web-panel/assets/{Wallet-D9-Q1Kan.js → Wallet-BbmP7Imu.js} +4 -4
- package/src/assets/web-panel/assets/{WebAuthn-BDszy-oV.js → WebAuthn-Dewj_0kt.js} +5 -5
- package/src/assets/web-panel/assets/{WorkflowEditor-DIhij4rA.js → WorkflowEditor-DOxz3Lwa.js} +1 -1
- package/src/assets/web-panel/assets/{chat-D7VCiKbh.js → chat-B1OIpZnh.js} +1 -1
- package/src/assets/web-panel/assets/{colors-BvOd9pVn.js → colors-CSGNVdri.js} +1 -1
- package/src/assets/web-panel/assets/{compact-item-DoqXBiAO.js → compact-item-8S0vcg0u.js} +1 -1
- package/src/assets/web-panel/assets/{createContext-CrKRzUtp.js → createContext-vZyc27TN.js} +1 -1
- package/src/assets/web-panel/assets/devWarning-DYEZcY55.js +1 -0
- package/src/assets/web-panel/assets/{hasIn-BWAev4WC.js → hasIn-DWw3-UhA.js} +1 -1
- package/src/assets/web-panel/assets/{index-DvDOpsJB.js → index-B4pP2ycr.js} +1 -1
- package/src/assets/web-panel/assets/{index-oY45XrkG.js → index-BDHyovnK.js} +1 -1
- package/src/assets/web-panel/assets/{index-CmJ6xKBO.js → index-BKEmQQvR.js} +1 -1
- package/src/assets/web-panel/assets/{index-BoYrS_L2.js → index-BQssf0EQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-ww1QtLnX.js → index-BZnauCqN.js} +1 -1
- package/src/assets/web-panel/assets/{index-BWWsaimP.js → index-BdyokEau.js} +1 -1
- package/src/assets/web-panel/assets/{index-CoD31Ndm.js → index-BeBv896m.js} +1 -1
- package/src/assets/web-panel/assets/{index-DlGu7As6.js → index-BjC-TrOb.js} +1 -1
- package/src/assets/web-panel/assets/{index-zH-tuuGZ.js → index-BkWd6YL7.js} +1 -1
- package/src/assets/web-panel/assets/{index-CEXfKSiI.js → index-BoIPHP4R.js} +1 -1
- package/src/assets/web-panel/assets/{index-CEPHMSih.js → index-BuB5P0TR.js} +1 -1
- package/src/assets/web-panel/assets/{index-PDxGjKRu.js → index-BwKrTVV0.js} +1 -1
- package/src/assets/web-panel/assets/{index-BkgnGX7v.js → index-C6r27A-o.js} +1 -1
- package/src/assets/web-panel/assets/{index-DV-Rfmfx.js → index-CB2MOS_8.js} +3 -3
- package/src/assets/web-panel/assets/{index-KR1-Em9C.js → index-CFQWZvak.js} +1 -1
- package/src/assets/web-panel/assets/{index-8g5TJCZ3.js → index-CI3slBKI.js} +1 -1
- package/src/assets/web-panel/assets/{index-BCyP071m.js → index-CJgKP2cm.js} +1 -1
- package/src/assets/web-panel/assets/{index-CSb-tnIo.js → index-CLPr2vC_.js} +1 -1
- package/src/assets/web-panel/assets/{index-1poMpMU0.js → index-CbZqw7pg.js} +1 -1
- package/src/assets/web-panel/assets/{index-BZGk8AbZ.js → index-CfEvacz7.js} +1 -1
- package/src/assets/web-panel/assets/{index-BXyl8O23.js → index-CwXBblm-.js} +1 -1
- package/src/assets/web-panel/assets/{index-BHOcVYrx.js → index-D1i_8oop.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bq_ctRpz.js → index-D8qXVfoV.js} +1 -1
- package/src/assets/web-panel/assets/{index-gmxqu5Kk.js → index-DKwl1AkQ.js} +1 -1
- package/src/assets/web-panel/assets/{index-D23L1V76.js → index-DLAvUtOH.js} +1 -1
- package/src/assets/web-panel/assets/{index-CDGLdn12.js → index-DLgkAhEE.js} +1 -1
- package/src/assets/web-panel/assets/{index-YGDy7nNq.js → index-DPgY5GLy.js} +1 -1
- package/src/assets/web-panel/assets/{index-Cx7M7PSF.js → index-Dc40pX3F.js} +1 -1
- package/src/assets/web-panel/assets/{index-JzUmJMvn.js → index-DcfLXAW7.js} +1 -1
- package/src/assets/web-panel/assets/{index-HwHdTusY.js → index-DeCS12Mh.js} +1 -1
- package/src/assets/web-panel/assets/{index-B30NvR21.js → index-Dg0Izipr.js} +1 -1
- package/src/assets/web-panel/assets/index-DmVFfcNR.js +1 -0
- package/src/assets/web-panel/assets/{index-B0vGEbJT.js → index-DtHWHJnj.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bf9YlbDn.js → index-Q6oyLnKC.js} +1 -1
- package/src/assets/web-panel/assets/{index-Bj7EgmOp.js → index-dxocDVD_.js} +1 -1
- package/src/assets/web-panel/assets/{index-ISvPZ05r.js → index-t-q3rkVm.js} +1 -1
- package/src/assets/web-panel/assets/{index-C5kRmj3P.js → index-tr1x96W9.js} +1 -1
- package/src/assets/web-panel/assets/index-x7YyhMUa.js +1 -0
- package/src/assets/web-panel/assets/{index-BN9m4Ux2.js → index-xx5M5Rbj.js} +1 -1
- package/src/assets/web-panel/assets/{initDefaultProps-XW-OR3mL.js → initDefaultProps-Di5YFId0.js} +1 -1
- package/src/assets/web-panel/assets/{motion-CeuEHh20.js → motion-BVSN0aeL.js} +1 -1
- package/src/assets/web-panel/assets/{move-Dva79Keg.js → move-DHNiaP2l.js} +1 -1
- package/src/assets/web-panel/assets/{mtc-parser-C0B6bD2j.js → mtc-parser-xJPf3gQD.js} +1 -1
- package/src/assets/web-panel/assets/{omit-AkZvO-a2.js → omit-C7qzUdkN.js} +1 -1
- package/src/assets/web-panel/assets/{pickAttrs-B4r55qGc.js → pickAttrs-pSpjg3Le.js} +1 -1
- package/src/assets/web-panel/assets/{placementArrow-BLH-z7YE.js → placementArrow-cs_jL_UC.js} +1 -1
- package/src/assets/web-panel/assets/{responsiveObserve-ksEGZ2Wf.js → responsiveObserve-DkS_Zs4r.js} +1 -1
- package/src/assets/web-panel/assets/{slide-Cth4OD1Z.js → slide-B5O1Kpxo.js} +1 -1
- package/src/assets/web-panel/assets/{statusUtils-9S83ezxw.js → statusUtils-BX02GnZp.js} +1 -1
- package/src/assets/web-panel/assets/{styleChecker-7yQmQGZ4.js → styleChecker-EmBYRftd.js} +1 -1
- package/src/assets/web-panel/assets/{useFlexGapSupport-DpGnMmPc.js → useFlexGapSupport-ZGzN1cU8.js} +1 -1
- package/src/assets/web-panel/assets/{useFs-C4zDZIXH.js → useFs-CfP7M1m7.js} +1 -1
- package/src/assets/web-panel/assets/{usePersonalDataHub-CoLICdUZ.js → usePersonalDataHub-CbIPHgNh.js} +1 -1
- package/src/assets/web-panel/assets/{vnode-CKyH-35X.js → vnode-7xh0If_O.js} +1 -1
- package/src/assets/web-panel/assets/{zoom-DRDGit17.js → zoom-DmitvTR2.js} +1 -1
- package/src/assets/web-panel/index.html +1 -1
- package/src/command-manifest.json +17 -3
- package/src/commands/agent.js +72 -6
- package/src/commands/background-session.js +173 -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 +107 -0
- package/src/commands/plugin.js +63 -1
- package/src/commands/routine.js +367 -0
- package/src/commands/session.js +396 -0
- package/src/data/changelog.json +12 -2
- package/src/harness/jsonl-session-store.js +152 -3
- package/src/harness/mcp-client.js +50 -0
- 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/agent-sandbox.js +68 -0
- package/src/lib/agents.js +12 -4
- package/src/lib/api-key-helper.js +62 -0
- package/src/lib/background-agent-supervisor.js +158 -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/dependency-install-policy.js +130 -0
- package/src/lib/doctor-checkup.js +652 -0
- package/src/lib/exit-codes.cjs +77 -0
- package/src/lib/headless-manifest.js +175 -0
- package/src/lib/hook-runner.cjs +70 -4
- package/src/lib/ide-context-redaction.js +130 -0
- package/src/lib/ide-context.js +89 -13
- package/src/lib/jsonl-session-store.js +6 -0
- package/src/lib/llm-config-defaults.js +24 -8
- 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/project-mcp-trust.js +87 -0
- package/src/lib/routine-store.js +352 -0
- package/src/lib/sensitive-file-guard.js +51 -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 +30 -3
- 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 +344 -7
- package/src/runtime/coding-agent-contract-shared.cjs +107 -2
- package/src/runtime/coding-agent-policy.cjs +17 -0
- package/src/runtime/headless-runner.js +137 -14
- package/src/runtime/headless-stream.js +43 -9
- package/src/runtime/mcp-config.js +32 -1
- 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/ChatBubbleRenderer-BAV9V-t-.js +0 -1
- package/src/assets/web-panel/assets/GenericCardRenderer-DxxEdn2r.js +0 -1
- package/src/assets/web-panel/assets/MobileProjects-CVtTqgtg.js +0 -1
- package/src/assets/web-panel/assets/Projects-COATmHxF.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
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
import { join, basename, resolve } from "node:path";
|
|
15
15
|
import { createHash } from "node:crypto";
|
|
16
16
|
import { getHomeDir } from "../lib/paths.js";
|
|
17
|
+
import {
|
|
18
|
+
computeEventHash,
|
|
19
|
+
latestChainHash,
|
|
20
|
+
verifyTranscriptText,
|
|
21
|
+
TRANSCRIPT_CHAIN_STATUS,
|
|
22
|
+
} from "./transcript-integrity.js";
|
|
17
23
|
|
|
18
24
|
function getSessionsDir() {
|
|
19
25
|
const dir = join(getHomeDir(), "sessions");
|
|
@@ -54,11 +60,66 @@ export function appendTokenUsage(sessionId, usage) {
|
|
|
54
60
|
appendEvent(sessionId, "token_usage", usage || {});
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
// Chain-tail cache: hash of the last chained record per session, so appends
|
|
64
|
+
// stay O(1) instead of re-reading the file. Keyed per process — two processes
|
|
65
|
+
// appending the SAME session concurrently could fork the chain (verify would
|
|
66
|
+
// then flag it), but the store has always assumed a single writer per session.
|
|
67
|
+
const _chainTailCache = new Map();
|
|
68
|
+
|
|
69
|
+
function _resolveChainTail(sessionId, filePath) {
|
|
70
|
+
if (!existsSync(filePath)) {
|
|
71
|
+
// Missing file self-heals a stale cache entry (file deleted externally,
|
|
72
|
+
// or a test tearing down the sessions dir between cases).
|
|
73
|
+
_chainTailCache.set(sessionId, null);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const cached = _chainTailCache.get(sessionId);
|
|
77
|
+
if (cached !== undefined) return cached;
|
|
78
|
+
const tail = latestChainHash(readFileSync(filePath, "utf-8"));
|
|
79
|
+
_chainTailCache.set(sessionId, tail);
|
|
80
|
+
return tail;
|
|
81
|
+
}
|
|
82
|
+
|
|
57
83
|
export function appendEvent(sessionId, type, data) {
|
|
58
|
-
const
|
|
59
|
-
|
|
84
|
+
const filePath = sessionPath(sessionId);
|
|
85
|
+
const prevHash = _resolveChainTail(sessionId, filePath);
|
|
86
|
+
const core = { type, timestamp: Date.now(), data };
|
|
87
|
+
const hash = computeEventHash(prevHash, core);
|
|
88
|
+
const line = JSON.stringify({ ...core, prevHash, hash }) + "\n";
|
|
89
|
+
appendFileSync(filePath, line, "utf-8");
|
|
90
|
+
_chainTailCache.set(sessionId, hash);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Verify a session transcript's hash chain (tamper-evidence).
|
|
95
|
+
* Statuses: verified | partial (legacy prefix + valid chain) | legacy
|
|
96
|
+
* (pre-chaining transcript) | tampered | empty — plus not-found / invalid-id.
|
|
97
|
+
*/
|
|
98
|
+
export function verifySession(sessionId) {
|
|
99
|
+
if (isUnsafeSessionId(sessionId)) {
|
|
100
|
+
return { sessionId, status: "invalid-id", reason: "invalid session id" };
|
|
101
|
+
}
|
|
102
|
+
const filePath = sessionPath(sessionId);
|
|
103
|
+
if (!existsSync(filePath)) {
|
|
104
|
+
return { sessionId, status: "not-found", reason: "session file not found" };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
sessionId,
|
|
108
|
+
...verifyTranscriptText(readFileSync(filePath, "utf-8")),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function verifyAllSessions(options = {}) {
|
|
113
|
+
const dir = getSessionsDir();
|
|
114
|
+
if (!existsSync(dir)) return [];
|
|
115
|
+
return readdirSync(dir)
|
|
116
|
+
.filter((file) => file.endsWith(".jsonl"))
|
|
117
|
+
.slice(0, options.limit || 1000)
|
|
118
|
+
.map((file) => verifySession(basename(file, ".jsonl")));
|
|
60
119
|
}
|
|
61
120
|
|
|
121
|
+
export { TRANSCRIPT_CHAIN_STATUS };
|
|
122
|
+
|
|
62
123
|
export function startSession(sessionId, meta = {}) {
|
|
63
124
|
const id =
|
|
64
125
|
sessionId ||
|
|
@@ -85,6 +146,25 @@ export function appendToolCall(sessionId, toolName, args) {
|
|
|
85
146
|
appendEvent(sessionId, "tool_call", { tool: toolName, args });
|
|
86
147
|
}
|
|
87
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Compact tool-call record for usage attribution (用量归因): tool name +
|
|
151
|
+
* error flag (+ an optional skill hint for `run_skill`) — deliberately NOT
|
|
152
|
+
* the args, which can carry whole file bodies (write_file content) and would
|
|
153
|
+
* bloat the transcript. Written at tool-result time by the agent drivers so
|
|
154
|
+
* `cc session usage --by tool|mcp` and `cc insights` can aggregate tool use
|
|
155
|
+
* for any persisted session.
|
|
156
|
+
*/
|
|
157
|
+
export function appendToolCallCompact(
|
|
158
|
+
sessionId,
|
|
159
|
+
{ tool, isError, skill } = {},
|
|
160
|
+
) {
|
|
161
|
+
appendEvent(sessionId, "tool_call", {
|
|
162
|
+
tool: tool || "?",
|
|
163
|
+
is_error: Boolean(isError),
|
|
164
|
+
...(skill ? { skill: String(skill) } : {}),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
88
168
|
export function appendToolResult(sessionId, toolName, result) {
|
|
89
169
|
appendEvent(sessionId, "tool_result", { tool: toolName, result });
|
|
90
170
|
}
|
|
@@ -191,10 +271,17 @@ export function listJsonlSessions(options = {}) {
|
|
|
191
271
|
const messageCount = events.filter(
|
|
192
272
|
(e) => e.type === "user_message" || e.type === "assistant_message",
|
|
193
273
|
).length;
|
|
274
|
+
// cc session rename appends session_rename events; the LAST one wins.
|
|
275
|
+
let renamedTitle = null;
|
|
276
|
+
for (const e of events) {
|
|
277
|
+
if (e.type === "session_rename" && e.data?.title) {
|
|
278
|
+
renamedTitle = e.data.title;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
194
281
|
|
|
195
282
|
return {
|
|
196
283
|
id,
|
|
197
|
-
title: startEvent?.data?.title || "Untitled",
|
|
284
|
+
title: renamedTitle || startEvent?.data?.title || "Untitled",
|
|
198
285
|
provider: startEvent?.data?.provider || "",
|
|
199
286
|
model: startEvent?.data?.model || "",
|
|
200
287
|
message_count: messageCount,
|
|
@@ -216,6 +303,68 @@ export function listJsonlSessions(options = {}) {
|
|
|
216
303
|
return files;
|
|
217
304
|
}
|
|
218
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Rename a session (gap-analysis 2026-07-11 P1 "命名会话"): appends a
|
|
308
|
+
* `session_rename` event so the hash chain stays intact (no rewrite). The
|
|
309
|
+
* LAST rename wins when listing/showing.
|
|
310
|
+
*/
|
|
311
|
+
export function renameSession(sessionId, title) {
|
|
312
|
+
if (!sessionExists(sessionId)) {
|
|
313
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
314
|
+
}
|
|
315
|
+
const normalized = String(title || "").trim();
|
|
316
|
+
if (!normalized) throw new Error("A non-empty title is required");
|
|
317
|
+
appendEvent(sessionId, "session_rename", { title: normalized.slice(0, 200) });
|
|
318
|
+
return { id: sessionId, title: normalized.slice(0, 200) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Prune old sessions (gap-analysis 2026-07-11 P1 "保留期限"): delete session
|
|
323
|
+
* transcripts whose LAST activity is older than `olderThanDays`, always
|
|
324
|
+
* keeping the newest `keep` (default 10) regardless of age. Dry-run returns
|
|
325
|
+
* the same shape without deleting.
|
|
326
|
+
*/
|
|
327
|
+
export function pruneJsonlSessions(options = {}) {
|
|
328
|
+
const olderThanDays = Number(options.olderThanDays);
|
|
329
|
+
if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
"prune requires --older-than <days> (a non-negative number)",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
const keep = Number.isFinite(Number(options.keep))
|
|
335
|
+
? Math.max(0, Math.floor(Number(options.keep)))
|
|
336
|
+
: 10;
|
|
337
|
+
const now = typeof options.now === "number" ? options.now : Date.now();
|
|
338
|
+
const cutoff = now - olderThanDays * 24 * 60 * 60 * 1000;
|
|
339
|
+
// listJsonlSessions sorts by last activity DESC — the first `keep` entries
|
|
340
|
+
// survive unconditionally.
|
|
341
|
+
const all = listJsonlSessions({ limit: 100000 });
|
|
342
|
+
const candidates = all.slice(keep).filter((s) => {
|
|
343
|
+
const last = Date.parse(s.updated_at || s.created_at || "");
|
|
344
|
+
return Number.isFinite(last) && last < cutoff;
|
|
345
|
+
});
|
|
346
|
+
const deleted = [];
|
|
347
|
+
for (const s of candidates) {
|
|
348
|
+
if (options.dryRun === true) {
|
|
349
|
+
deleted.push(s.id);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
rmSync(sessionPath(s.id), { force: true });
|
|
354
|
+
_chainTailCache.delete(s.id);
|
|
355
|
+
deleted.push(s.id);
|
|
356
|
+
} catch {
|
|
357
|
+
/* per-file failures never abort the sweep */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
scanned: all.length,
|
|
362
|
+
kept: all.length - deleted.length,
|
|
363
|
+
deleted,
|
|
364
|
+
dryRun: options.dryRun === true,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
219
368
|
export function forkSession(sourceId) {
|
|
220
369
|
const events = readEvents(sourceId);
|
|
221
370
|
if (events.length === 0) return null;
|
|
@@ -1051,6 +1051,17 @@ export class MCPClient extends EventEmitter {
|
|
|
1051
1051
|
|
|
1052
1052
|
// Server notification
|
|
1053
1053
|
if (msg.method) {
|
|
1054
|
+
// tools/resources list_changed (gap 2026-07-11 MCP 生命周期): refetch
|
|
1055
|
+
// the changed list so entry.tools/entry.resources stay live —
|
|
1056
|
+
// `listTools()`, `/mcp` status and callTool routing all see the update.
|
|
1057
|
+
// (The LLM tool array of an in-flight turn is deliberately NOT mutated:
|
|
1058
|
+
// tool-search's prompt-cache stability depends on an append-only,
|
|
1059
|
+
// stable-prefix tool list.)
|
|
1060
|
+
if (msg.method === "notifications/tools/list_changed") {
|
|
1061
|
+
this._refreshServerList(serverName, "tools");
|
|
1062
|
+
} else if (msg.method === "notifications/resources/list_changed") {
|
|
1063
|
+
this._refreshServerList(serverName, "resources");
|
|
1064
|
+
}
|
|
1054
1065
|
this.emit("notification", {
|
|
1055
1066
|
server: serverName,
|
|
1056
1067
|
method: msg.method,
|
|
@@ -1059,6 +1070,45 @@ export class MCPClient extends EventEmitter {
|
|
|
1059
1070
|
}
|
|
1060
1071
|
}
|
|
1061
1072
|
|
|
1073
|
+
/**
|
|
1074
|
+
* Re-fetch a server's tools / resources list after a `*_list_changed`
|
|
1075
|
+
* notification. Coalesced per server+kind: a burst of notifications folds
|
|
1076
|
+
* into the in-flight refetch plus at most one trailing pass. Best-effort —
|
|
1077
|
+
* a failed refetch keeps the previous list and waits for the next
|
|
1078
|
+
* notification. Emits "tools-changed" / "resources-changed" on update.
|
|
1079
|
+
*/
|
|
1080
|
+
async _refreshServerList(serverName, kind) {
|
|
1081
|
+
const entry = this.servers.get(serverName);
|
|
1082
|
+
if (!entry) return;
|
|
1083
|
+
const flags = (entry._listRefresh = entry._listRefresh || {});
|
|
1084
|
+
if (flags[`${kind}Running`]) {
|
|
1085
|
+
flags[`${kind}Dirty`] = true;
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
flags[`${kind}Running`] = true;
|
|
1089
|
+
try {
|
|
1090
|
+
do {
|
|
1091
|
+
flags[`${kind}Dirty`] = false;
|
|
1092
|
+
try {
|
|
1093
|
+
const method = kind === "tools" ? "tools/list" : "resources/list";
|
|
1094
|
+
const result = await this._sendRequest(serverName, method, {});
|
|
1095
|
+
const list =
|
|
1096
|
+
(kind === "tools" ? result?.tools : result?.resources) || [];
|
|
1097
|
+
entry[kind] = list;
|
|
1098
|
+
if (kind === "tools") entry.toolsError = null;
|
|
1099
|
+
this.emit(`${kind}-changed`, {
|
|
1100
|
+
server: serverName,
|
|
1101
|
+
count: list.length,
|
|
1102
|
+
});
|
|
1103
|
+
} catch {
|
|
1104
|
+
break; // keep the previous list; the next notification retries
|
|
1105
|
+
}
|
|
1106
|
+
} while (flags[`${kind}Dirty`]);
|
|
1107
|
+
} finally {
|
|
1108
|
+
flags[`${kind}Running`] = false;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1062
1112
|
/**
|
|
1063
1113
|
* Answer a server-initiated JSON-RPC request. `roots/list` returns the
|
|
1064
1114
|
* session's workspace roots (we advertise the roots capability); `ping`
|
|
@@ -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
|
+
}
|