mirror-mind 0.0.1 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/hooks/log-assistant-reply.sh +85 -0
- package/.claude/hooks/log-session-end.sh +85 -0
- package/.claude/hooks/log-user-prompt.sh +85 -0
- package/.claude/hooks/mirror-inject.sh +85 -0
- package/.claude/hooks/session-start.sh +85 -0
- package/.gemini/hooks/log-assistant.sh +85 -0
- package/.gemini/hooks/log-user.sh +85 -0
- package/.gemini/hooks/session-end.sh +85 -0
- package/.gemini/hooks/session-start.sh +85 -0
- package/.pi/extensions/mirror-logger.ts +646 -0
- package/.pi/skills/mm-backup/SKILL.md +21 -0
- package/.pi/skills/mm-build/SKILL.md +1206 -0
- package/.pi/skills/mm-consolidate/SKILL.md +112 -0
- package/.pi/skills/mm-consult/SKILL.md +70 -0
- package/.pi/skills/mm-conversations/SKILL.md +16 -0
- package/.pi/skills/mm-discard/SKILL.md +39 -0
- package/.pi/skills/mm-explore/SKILL.md +255 -0
- package/.pi/skills/mm-help/SKILL.md +74 -0
- package/.pi/skills/mm-identity/SKILL.md +96 -0
- package/.pi/skills/mm-journal/SKILL.md +16 -0
- package/.pi/skills/mm-journey/SKILL.md +23 -0
- package/.pi/skills/mm-journeys/SKILL.md +15 -0
- package/.pi/skills/mm-memories/SKILL.md +17 -0
- package/.pi/skills/mm-mirror/SKILL.md +98 -0
- package/.pi/skills/mm-mute/SKILL.md +18 -0
- package/.pi/skills/mm-new/SKILL.md +18 -0
- package/.pi/skills/mm-recall/SKILL.md +17 -0
- package/.pi/skills/mm-release-notes/SKILL.md +52 -0
- package/.pi/skills/mm-seed/SKILL.md +19 -0
- package/.pi/skills/mm-shadow/SKILL.md +117 -0
- package/.pi/skills/mm-soul/SKILL.md +502 -0
- package/.pi/skills/mm-tasks/SKILL.md +24 -0
- package/.pi/skills/mm-update/SKILL.md +32 -0
- package/.pi/skills/mm-week/SKILL.md +28 -0
- package/.pi/skills/mm-welcome/SKILL.md +24 -0
- package/AGENTS.md +188 -0
- package/README.md +126 -9
- package/bin/loader.js +67 -0
- package/bin/mirror-hook.js +7 -0
- package/bin/mirror.js +7 -0
- package/docs/releases/index.md +100 -0
- package/docs/releases/pending-cutoffs.md +510 -0
- package/docs/releases/v0.10.0.md +123 -0
- package/docs/releases/v0.10.1.md +66 -0
- package/docs/releases/v0.10.10.md +51 -0
- package/docs/releases/v0.10.11.md +39 -0
- package/docs/releases/v0.10.12.md +57 -0
- package/docs/releases/v0.10.13.md +35 -0
- package/docs/releases/v0.10.14.md +42 -0
- package/docs/releases/v0.10.15.md +53 -0
- package/docs/releases/v0.10.16.md +35 -0
- package/docs/releases/v0.10.17.md +43 -0
- package/docs/releases/v0.10.2.md +64 -0
- package/docs/releases/v0.10.3.md +78 -0
- package/docs/releases/v0.10.4.md +42 -0
- package/docs/releases/v0.10.5.md +57 -0
- package/docs/releases/v0.10.6.md +47 -0
- package/docs/releases/v0.10.7.md +53 -0
- package/docs/releases/v0.10.8.md +49 -0
- package/docs/releases/v0.10.9.md +37 -0
- package/docs/releases/v0.11.0.md +86 -0
- package/docs/releases/v0.12.0.md +67 -0
- package/docs/releases/v0.13.0.md +64 -0
- package/docs/releases/v0.14.0.md +68 -0
- package/docs/releases/v0.15.0.md +48 -0
- package/docs/releases/v0.16.0.md +50 -0
- package/docs/releases/v0.16.1.md +55 -0
- package/docs/releases/v0.16.2.md +36 -0
- package/docs/releases/v0.17.0.md +50 -0
- package/docs/releases/v0.17.1.md +58 -0
- package/docs/releases/v0.17.2.md +42 -0
- package/docs/releases/v0.17.3.md +58 -0
- package/docs/releases/v0.18.0.md +84 -0
- package/docs/releases/v0.18.1.md +51 -0
- package/docs/releases/v0.18.2.md +69 -0
- package/docs/releases/v0.19.0.md +85 -0
- package/docs/releases/v0.19.1.md +46 -0
- package/docs/releases/v0.20.0.md +85 -0
- package/docs/releases/v0.21.0.md +61 -0
- package/docs/releases/v0.21.1.md +47 -0
- package/docs/releases/v0.22.0.md +77 -0
- package/docs/releases/v0.22.1.md +42 -0
- package/docs/releases/v0.23.0.md +105 -0
- package/docs/releases/v0.24.0.md +125 -0
- package/docs/releases/v0.25.0.md +107 -0
- package/docs/releases/v0.26.0.md +139 -0
- package/docs/releases/v0.26.1.md +65 -0
- package/docs/releases/v0.26.2.md +43 -0
- package/docs/releases/v0.27.0.md +63 -0
- package/docs/releases/v0.28.0.md +59 -0
- package/docs/releases/v0.29.0.md +61 -0
- package/docs/releases/v0.29.1.md +59 -0
- package/docs/releases/v0.29.2.md +59 -0
- package/docs/releases/v0.29.3.md +93 -0
- package/docs/releases/v0.30.0.md +75 -0
- package/docs/releases/v0.30.1.md +93 -0
- package/docs/releases/v0.30.2.md +66 -0
- package/docs/releases/v0.31.0.md +122 -0
- package/docs/releases/v0.31.1.md +142 -0
- package/docs/releases/v0.31.10.md +118 -0
- package/docs/releases/v0.31.11.md +88 -0
- package/docs/releases/v0.31.12.md +105 -0
- package/docs/releases/v0.31.13.md +99 -0
- package/docs/releases/v0.31.14.md +97 -0
- package/docs/releases/v0.31.2.md +115 -0
- package/docs/releases/v0.31.3.md +142 -0
- package/docs/releases/v0.31.4.md +117 -0
- package/docs/releases/v0.31.5.md +155 -0
- package/docs/releases/v0.31.6.md +132 -0
- package/docs/releases/v0.31.7.md +132 -0
- package/docs/releases/v0.31.8.md +120 -0
- package/docs/releases/v0.31.9.md +120 -0
- package/docs/releases/v0.32.0.md +261 -0
- package/docs/releases/v0.32.1.md +59 -0
- package/docs/releases/v0.33.0.md +164 -0
- package/docs/releases/v0.8.0.md +118 -0
- package/docs/releases/v0.9.0.md +98 -0
- package/docs/releases/v0.9.1.md +83 -0
- package/package.json +102 -6
- package/plugins/mirror-mind/.claude-plugin/plugin.json +17 -0
- package/plugins/mirror-mind/hooks/hooks.json +62 -0
- package/plugins/mirror-mind/hooks/log-assistant-reply.sh +98 -0
- package/plugins/mirror-mind/hooks/log-session-end.sh +98 -0
- package/plugins/mirror-mind/hooks/log-user-prompt.sh +98 -0
- package/plugins/mirror-mind/hooks/mirror-inject.sh +98 -0
- package/plugins/mirror-mind/hooks/session-start.sh +98 -0
- package/plugins/mirror-mind/skills/mm-backup/SKILL.md +27 -0
- package/plugins/mirror-mind/skills/mm-build/SKILL.md +1206 -0
- package/plugins/mirror-mind/skills/mm-consolidate/SKILL.md +112 -0
- package/plugins/mirror-mind/skills/mm-consult/SKILL.md +83 -0
- package/plugins/mirror-mind/skills/mm-conversations/SKILL.md +17 -0
- package/plugins/mirror-mind/skills/mm-discard/SKILL.md +40 -0
- package/plugins/mirror-mind/skills/mm-explore/SKILL.md +241 -0
- package/plugins/mirror-mind/skills/mm-help/SKILL.md +104 -0
- package/plugins/mirror-mind/skills/mm-identity/SKILL.md +96 -0
- package/plugins/mirror-mind/skills/mm-journal/SKILL.md +21 -0
- package/plugins/mirror-mind/skills/mm-journey/SKILL.md +37 -0
- package/plugins/mirror-mind/skills/mm-journeys/SKILL.md +15 -0
- package/plugins/mirror-mind/skills/mm-memories/SKILL.md +20 -0
- package/plugins/mirror-mind/skills/mm-mirror/SKILL.md +67 -0
- package/plugins/mirror-mind/skills/mm-mute/SKILL.md +18 -0
- package/plugins/mirror-mind/skills/mm-new/SKILL.md +18 -0
- package/plugins/mirror-mind/skills/mm-recall/SKILL.md +17 -0
- package/plugins/mirror-mind/skills/mm-release-notes/SKILL.md +31 -0
- package/plugins/mirror-mind/skills/mm-seed/SKILL.md +15 -0
- package/plugins/mirror-mind/skills/mm-shadow/SKILL.md +117 -0
- package/plugins/mirror-mind/skills/mm-soul/SKILL.md +488 -0
- package/plugins/mirror-mind/skills/mm-tasks/SKILL.md +36 -0
- package/plugins/mirror-mind/skills/mm-update/SKILL.md +30 -0
- package/plugins/mirror-mind/skills/mm-week/SKILL.md +46 -0
- package/plugins/mirror-mind/skills/mm-welcome/SKILL.md +22 -0
- package/scripts/codex-hooks/session-end.sh +85 -0
- package/scripts/codex-hooks/session-start.sh +85 -0
- package/scripts/codex-mirror.sh +48 -0
- package/templates/identity/README.md +119 -0
- package/templates/identity/ego/behavior.yaml +52 -0
- package/templates/identity/ego/constraints.yaml +15 -0
- package/templates/identity/ego/identity.yaml +43 -0
- package/templates/identity/journeys/personal-growth.yaml +39 -0
- package/templates/identity/organization/identity.yaml +24 -0
- package/templates/identity/organization/principles.yaml +17 -0
- package/templates/identity/personas/coach.yaml +94 -0
- package/templates/identity/personas/designer.yaml +93 -0
- package/templates/identity/personas/doctor.yaml +96 -0
- package/templates/identity/personas/engineer.yaml +77 -0
- package/templates/identity/personas/financial.yaml +97 -0
- package/templates/identity/personas/prompt-engineer.yaml +122 -0
- package/templates/identity/personas/researcher.yaml +88 -0
- package/templates/identity/personas/strategist.yaml +91 -0
- package/templates/identity/personas/teacher.yaml +95 -0
- package/templates/identity/personas/therapist.yaml +91 -0
- package/templates/identity/personas/thinker.yaml +74 -0
- package/templates/identity/personas/writer.yaml +76 -0
- package/templates/identity/self/config.yaml +16 -0
- package/templates/identity/self/soul.yaml +105 -0
- package/templates/identity/user/identity.yaml +30 -0
- package/ts/src/backup/staging.ts +62 -0
- package/ts/src/backup/zipBackup.ts +267 -0
- package/ts/src/backup/zipReader.ts +109 -0
- package/ts/src/backup/zipWriter.ts +148 -0
- package/ts/src/builder/activeCheckpoint.ts +134 -0
- package/ts/src/builder/aggregateStatus.ts +34 -0
- package/ts/src/builder/approve.ts +149 -0
- package/ts/src/builder/argv.ts +292 -0
- package/ts/src/builder/ariadMethod.ts +536 -0
- package/ts/src/builder/artifacts/artifactSurfaces.ts +106 -0
- package/ts/src/builder/artifacts/artifactWriter.ts +113 -0
- package/ts/src/builder/artifacts/closureArtifacts.ts +199 -0
- package/ts/src/builder/artifacts/deliveryStoryArtifacts.ts +316 -0
- package/ts/src/builder/artifacts/planArtifacts.ts +242 -0
- package/ts/src/builder/artifacts/scaffoldSections.ts +273 -0
- package/ts/src/builder/artifacts/scaffoldState.ts +227 -0
- package/ts/src/builder/artifacts/storyFiles.ts +61 -0
- package/ts/src/builder/artifacts/storyIndex.ts +166 -0
- package/ts/src/builder/cadence.ts +59 -0
- package/ts/src/builder/card.ts +192 -0
- package/ts/src/builder/checkpointRefused.ts +66 -0
- package/ts/src/builder/closure.ts +964 -0
- package/ts/src/builder/commands.ts +2053 -0
- package/ts/src/builder/cursorPosition.ts +176 -0
- package/ts/src/builder/cursorTransitions.ts +77 -0
- package/ts/src/builder/deliveryCursor.ts +577 -0
- package/ts/src/builder/deliveryStoryClosure.ts +521 -0
- package/ts/src/builder/deliveryStoryPlan.ts +536 -0
- package/ts/src/builder/deliveryStoryReady.ts +160 -0
- package/ts/src/builder/deliveryStoryRoadmapClosure.ts +151 -0
- package/ts/src/builder/expand.ts +336 -0
- package/ts/src/builder/flowUnit.ts +339 -0
- package/ts/src/builder/homeSurface.ts +122 -0
- package/ts/src/builder/implementationGuard.ts +125 -0
- package/ts/src/builder/index.ts +11 -0
- package/ts/src/builder/lifecycleRefusal.ts +92 -0
- package/ts/src/builder/lifecycleRibbon.ts +159 -0
- package/ts/src/builder/load.ts +313 -0
- package/ts/src/builder/methodAdoption.ts +113 -0
- package/ts/src/builder/methodDefinition.ts +429 -0
- package/ts/src/builder/methodInspection.ts +371 -0
- package/ts/src/builder/plan.ts +408 -0
- package/ts/src/builder/planPreauthorization.ts +265 -0
- package/ts/src/builder/prepare.ts +225 -0
- package/ts/src/builder/projectPaths.ts +64 -0
- package/ts/src/builder/pull.ts +356 -0
- package/ts/src/builder/pullCandidates.ts +463 -0
- package/ts/src/builder/pullCandidatesRender.ts +238 -0
- package/ts/src/builder/refinementField.ts +93 -0
- package/ts/src/builder/releaseIntent.ts +173 -0
- package/ts/src/builder/resumeState.ts +84 -0
- package/ts/src/builder/resumeSurface.ts +149 -0
- package/ts/src/builder/roadmapGrammar.ts +300 -0
- package/ts/src/builder/roadmapScan.ts +97 -0
- package/ts/src/builder/roadmapScope.ts +210 -0
- package/ts/src/builder/scopePhrases.ts +146 -0
- package/ts/src/builder/storyPaths.ts +196 -0
- package/ts/src/builder/storyPlanPreauthorization.ts +287 -0
- package/ts/src/builder/surfaceProtocol.ts +42 -0
- package/ts/src/builder/templateGeneration.ts +148 -0
- package/ts/src/builder/transition.ts +178 -0
- package/ts/src/consult/args.ts +99 -0
- package/ts/src/consult/core.ts +122 -0
- package/ts/src/consult/modelCatalog.ts +55 -0
- package/ts/src/consult/render.ts +47 -0
- package/ts/src/conversation/append.ts +414 -0
- package/ts/src/conversation/backfill.ts +387 -0
- package/ts/src/conversation/closeTail.ts +467 -0
- package/ts/src/conversation/extraction.ts +333 -0
- package/ts/src/conversation/extractionDriver.ts +130 -0
- package/ts/src/conversation/extractionRun.ts +70 -0
- package/ts/src/conversation/hookPayload.ts +51 -0
- package/ts/src/conversation/journeyInference.ts +165 -0
- package/ts/src/conversation/journeyRepair.ts +114 -0
- package/ts/src/conversation/lifecycleFaces.ts +197 -0
- package/ts/src/conversation/lifecycleWrites.ts +313 -0
- package/ts/src/conversation/listing.ts +77 -0
- package/ts/src/conversation/logger.ts +851 -0
- package/ts/src/conversation/loggerCli.ts +321 -0
- package/ts/src/conversation/loggerRuntime.ts +259 -0
- package/ts/src/conversation/metadataLifecycle.ts +443 -0
- package/ts/src/conversation/recall.ts +66 -0
- package/ts/src/conversation/sessionComposites.ts +258 -0
- package/ts/src/conversation/sessionImport.ts +90 -0
- package/ts/src/conversation/transcriptBackfill.ts +141 -0
- package/ts/src/cultivation/applyActions.ts +241 -0
- package/ts/src/cultivation/cluster.ts +86 -0
- package/ts/src/cultivation/consolidationStore.ts +281 -0
- package/ts/src/cultivation/promptContext.ts +62 -0
- package/ts/src/cultivation/propose.ts +305 -0
- package/ts/src/cultivation/scan.ts +155 -0
- package/ts/src/db/backupGate.ts +47 -0
- package/ts/src/db/bootstrap.ts +108 -0
- package/ts/src/db/bootstrapLock.ts +223 -0
- package/ts/src/db/copyGuard.ts +60 -0
- package/ts/src/db/database.ts +397 -0
- package/ts/src/db/decode.ts +66 -0
- package/ts/src/db/ftsIntegrity.ts +41 -0
- package/ts/src/db/migrateOnOpen.ts +214 -0
- package/ts/src/db/migrations.ts +576 -0
- package/ts/src/db/readyOnOpen.ts +35 -0
- package/ts/src/db/rowDecode.ts +42 -0
- package/ts/src/db/schema.ts +432 -0
- package/ts/src/db/schemaInventory.ts +225 -0
- package/ts/src/db/schemaInventorySnapshot.ts +2107 -0
- package/ts/src/db/schemaState.ts +91 -0
- package/ts/src/db/schemaTsDivergence.ts +114 -0
- package/ts/src/descriptor/descriptorRead.ts +49 -0
- package/ts/src/docs/docsLint.ts +265 -0
- package/ts/src/explorer/handoff.ts +522 -0
- package/ts/src/explorer/render.ts +335 -0
- package/ts/src/explorer/story.ts +769 -0
- package/ts/src/explorer/transition.ts +72 -0
- package/ts/src/extensions/bindings.ts +170 -0
- package/ts/src/extensions/catalog.ts +249 -0
- package/ts/src/extensions/catalogCommands.ts +513 -0
- package/ts/src/extensions/catalogWrites.ts +652 -0
- package/ts/src/extensions/contextRuntime.ts +325 -0
- package/ts/src/extensions/dispatch.ts +374 -0
- package/ts/src/extensions/errors.ts +24 -0
- package/ts/src/extensions/manifest.ts +356 -0
- package/ts/src/extensions/migrations.ts +350 -0
- package/ts/src/extraction/conversation.ts +304 -0
- package/ts/src/extraction/conversationMetadata.ts +206 -0
- package/ts/src/extraction/fencing.ts +106 -0
- package/ts/src/extraction/json.ts +17 -0
- package/ts/src/extraction/prompts.ts +470 -0
- package/ts/src/frontDoor/args.ts +30 -0
- package/ts/src/frontDoor/argvShape.ts +106 -0
- package/ts/src/frontDoor/buildLoadRuntime.ts +139 -0
- package/ts/src/frontDoor/buildRoute.ts +398 -0
- package/ts/src/frontDoor/cli.ts +1872 -0
- package/ts/src/frontDoor/cloneRoleGuard.ts +98 -0
- package/ts/src/frontDoor/consultRoute.ts +50 -0
- package/ts/src/frontDoor/contentTailRoute.ts +239 -0
- package/ts/src/frontDoor/conversationLoggerRoute.ts +83 -0
- package/ts/src/frontDoor/cultivationRoute.ts +210 -0
- package/ts/src/frontDoor/dbPath.ts +131 -0
- package/ts/src/frontDoor/dbSafetyToolsRoute.ts +240 -0
- package/ts/src/frontDoor/exploreRoute.ts +572 -0
- package/ts/src/frontDoor/extensionCatalogRoute.ts +252 -0
- package/ts/src/frontDoor/frontDoorLog.ts +57 -0
- package/ts/src/frontDoor/identityWrite.ts +36 -0
- package/ts/src/frontDoor/journeyWriteRoute.ts +26 -0
- package/ts/src/frontDoor/lifecycleWriteRoute.ts +110 -0
- package/ts/src/frontDoor/liveBackup.ts +100 -0
- package/ts/src/frontDoor/mirrorModeRoute.ts +163 -0
- package/ts/src/frontDoor/namedRefusal.ts +35 -0
- package/ts/src/frontDoor/nodeSupport.ts +24 -0
- package/ts/src/frontDoor/render/consolidate.ts +199 -0
- package/ts/src/frontDoor/render/conversations.ts +33 -0
- package/ts/src/frontDoor/render/cultivationShared.ts +21 -0
- package/ts/src/frontDoor/render/descriptor.ts +20 -0
- package/ts/src/frontDoor/render/detectPersona.ts +50 -0
- package/ts/src/frontDoor/render/icons.ts +13 -0
- package/ts/src/frontDoor/render/identity.ts +59 -0
- package/ts/src/frontDoor/render/identityRows.ts +17 -0
- package/ts/src/frontDoor/render/inspectPersona.ts +53 -0
- package/ts/src/frontDoor/render/journeyStatus.ts +78 -0
- package/ts/src/frontDoor/render/journeys.ts +79 -0
- package/ts/src/frontDoor/render/list.ts +41 -0
- package/ts/src/frontDoor/render/memories.ts +67 -0
- package/ts/src/frontDoor/render/recall.ts +49 -0
- package/ts/src/frontDoor/render/shadow.ts +155 -0
- package/ts/src/frontDoor/render/tasks.ts +99 -0
- package/ts/src/frontDoor/render/tasksImportSync.ts +63 -0
- package/ts/src/frontDoor/render/week.ts +113 -0
- package/ts/src/frontDoor/routing.ts +1001 -0
- package/ts/src/frontDoor/runtimeRoute.ts +623 -0
- package/ts/src/frontDoor/searchRoute.ts +166 -0
- package/ts/src/frontDoor/seedPaths.ts +98 -0
- package/ts/src/frontDoor/sessionStartWelcomeRoute.ts +166 -0
- package/ts/src/frontDoor/soulRoute.ts +433 -0
- package/ts/src/frontDoor/stdinSource.ts +37 -0
- package/ts/src/frontDoor/tasksImportSyncRoute.ts +110 -0
- package/ts/src/frontDoor/tasksWriteRoute.ts +101 -0
- package/ts/src/frontDoor/usage.ts +176 -0
- package/ts/src/guards/claudePlugin.ts +298 -0
- package/ts/src/guards/packContents.ts +306 -0
- package/ts/src/guards/piSourcedSkills.ts +70 -0
- package/ts/src/guards/retiredRouteShadows.ts +57 -0
- package/ts/src/guards/retiredSurfaces.ts +564 -0
- package/ts/src/hooks/claude.ts +214 -0
- package/ts/src/hooks/codex.ts +45 -0
- package/ts/src/hooks/gemini.ts +126 -0
- package/ts/src/hooks/main.ts +109 -0
- package/ts/src/hooks/mirrorState.ts +71 -0
- package/ts/src/hooks/payload.ts +73 -0
- package/ts/src/hooks/runtime.ts +179 -0
- package/ts/src/hooks/runtimeDetection.ts +34 -0
- package/ts/src/identity/applyConsolidationIdentityUpdate.ts +67 -0
- package/ts/src/identity/descriptorGenerate.ts +162 -0
- package/ts/src/identity/identityEdit.ts +143 -0
- package/ts/src/identity/identityKey.ts +77 -0
- package/ts/src/identity/identityRead.ts +69 -0
- package/ts/src/identity/identityStore.ts +80 -0
- package/ts/src/identity/journeyListing.ts +53 -0
- package/ts/src/identity/personaInspect.ts +44 -0
- package/ts/src/identity/personaListing.ts +37 -0
- package/ts/src/identity/setIdentity.ts +64 -0
- package/ts/src/index.ts +231 -0
- package/ts/src/init/init.ts +111 -0
- package/ts/src/journey/journeyOptions.ts +131 -0
- package/ts/src/journey/journeyRemoval.ts +158 -0
- package/ts/src/journey/journeyStatus.ts +149 -0
- package/ts/src/journey/journeySyncFile.ts +87 -0
- package/ts/src/journey/journeyWrite.ts +203 -0
- package/ts/src/journey/parentJourney.ts +41 -0
- package/ts/src/journey/validateParentJourney.ts +55 -0
- package/ts/src/mcp/boundary.ts +151 -0
- package/ts/src/mcp/guards.ts +244 -0
- package/ts/src/mcp/main.ts +121 -0
- package/ts/src/mcp/payload.ts +141 -0
- package/ts/src/mcp/protocol.ts +141 -0
- package/ts/src/mcp/readModels.ts +241 -0
- package/ts/src/mcp/registry.ts +201 -0
- package/ts/src/mcp/serve.ts +120 -0
- package/ts/src/mcp/tools/deterministic.ts +137 -0
- package/ts/src/mcp/tools/providerCrossing.ts +187 -0
- package/ts/src/mcp/wire.ts +8 -0
- package/ts/src/memory/journal.ts +180 -0
- package/ts/src/memory/listing.ts +107 -0
- package/ts/src/memory/memoryWrite.ts +88 -0
- package/ts/src/memory/reinforcement.ts +35 -0
- package/ts/src/mirror/context.ts +161 -0
- package/ts/src/mirror/defaultResolution.ts +199 -0
- package/ts/src/mirror/mirrorForm.ts +43 -0
- package/ts/src/mirror/orchestration.ts +364 -0
- package/ts/src/mirror/reception.ts +194 -0
- package/ts/src/mirror/render.ts +75 -0
- package/ts/src/mirror/runtimeSession.ts +205 -0
- package/ts/src/mode/operatingMode.ts +148 -0
- package/ts/src/observability/callOutcome.ts +115 -0
- package/ts/src/observability/inspectLedger.ts +329 -0
- package/ts/src/observability/ledgerHooks.ts +126 -0
- package/ts/src/observability/llmCalls.ts +154 -0
- package/ts/src/persona/detectPersona.ts +81 -0
- package/ts/src/planning/promptAssembly.ts +111 -0
- package/ts/src/planning/weekPending.ts +81 -0
- package/ts/src/planning/weekPlan.ts +183 -0
- package/ts/src/planning/weekSave.ts +117 -0
- package/ts/src/providers/config.ts +189 -0
- package/ts/src/providers/cost.ts +64 -0
- package/ts/src/providers/credits.ts +204 -0
- package/ts/src/providers/embedding.ts +321 -0
- package/ts/src/providers/familyProviders.ts +147 -0
- package/ts/src/providers/llm.ts +334 -0
- package/ts/src/providers/openrouter.ts +244 -0
- package/ts/src/providers/redaction.ts +110 -0
- package/ts/src/providers/replay.ts +15 -0
- package/ts/src/providers/transport.ts +410 -0
- package/ts/src/repair/encodingRepair.ts +253 -0
- package/ts/src/runtime/backup.ts +151 -0
- package/ts/src/runtime/channel.ts +146 -0
- package/ts/src/runtime/config.ts +168 -0
- package/ts/src/runtime/diagnose.ts +686 -0
- package/ts/src/runtime/git.ts +510 -0
- package/ts/src/runtime/installKind.ts +220 -0
- package/ts/src/runtime/migrate.ts +154 -0
- package/ts/src/runtime/packageIdentity.ts +102 -0
- package/ts/src/runtime/releaseNotes.ts +244 -0
- package/ts/src/runtime/status.ts +557 -0
- package/ts/src/runtime/strategies/clone.ts +200 -0
- package/ts/src/runtime/strategies/package.ts +328 -0
- package/ts/src/runtime/treeRoot.ts +30 -0
- package/ts/src/runtime/update.ts +445 -0
- package/ts/src/runtime/updateGate.ts +40 -0
- package/ts/src/runtime/updatePipeline.ts +159 -0
- package/ts/src/runtime/version.ts +36 -0
- package/ts/src/runtime/wiring.ts +130 -0
- package/ts/src/search/memorySearch.ts +338 -0
- package/ts/src/search/ranker.ts +154 -0
- package/ts/src/seed/seed.ts +256 -0
- package/ts/src/seed/seedYaml.ts +133 -0
- package/ts/src/soul/apply.ts +218 -0
- package/ts/src/soul/harvest.ts +267 -0
- package/ts/src/soul/prompts/soul_beauty_voice.md +103 -0
- package/ts/src/soul/prompts/soul_self_voice.md +219 -0
- package/ts/src/soul/prompts/soul_wisdom_voice.md +121 -0
- package/ts/src/soul/prompts.ts +85 -0
- package/ts/src/soul/render.ts +358 -0
- package/ts/src/soul/state.ts +212 -0
- package/ts/src/soul/transition.ts +37 -0
- package/ts/src/tasks/journeyPathParse.ts +113 -0
- package/ts/src/tasks/taskImportSync.ts +142 -0
- package/ts/src/tasks/taskStore.ts +312 -0
- package/ts/src/tasks/weekView.ts +120 -0
- package/ts/src/util/clipCodePoints.ts +26 -0
- package/ts/src/util/paths.ts +63 -0
- package/ts/src/util/program.ts +9 -0
- package/ts/src/util/pyGenerators.ts +144 -0
- package/ts/src/util/pythonText.ts +317 -0
- package/ts/src/util/semver.ts +45 -0
- package/ts/src/util/shellWord.ts +25 -0
- package/ts/src/util/slug.ts +64 -0
- package/ts/src/welcome/card.ts +339 -0
- package/ts/src/welcome/statusLine.ts +101 -0
- package/ts/src/welcome/updateCache.ts +129 -0
|
@@ -0,0 +1,851 @@
|
|
|
1
|
+
// CV22.DS7.US5 slice A — deterministic conversation-logger core.
|
|
2
|
+
//
|
|
3
|
+
// Ports the no-LLM paths of `src/memory/cli/conversation_logger.py`
|
|
4
|
+
// (log_user_message, log_assistant_message, _generate_title, mute flag) and
|
|
5
|
+
// the transactional `runtime_session.get_or_create_conversation` service.
|
|
6
|
+
// Byte-shapes follow Python exactly: title truncation, whitespace collapse,
|
|
7
|
+
// and `json.dumps`-style metadata separators (see `pythonJsonDumps`).
|
|
8
|
+
//
|
|
9
|
+
// The LLM-backed tails of this family (extraction, close-time metadata
|
|
10
|
+
// finalization) are slice C/D scope behind the replay transport; nothing in
|
|
11
|
+
// this module may call a provider.
|
|
12
|
+
|
|
13
|
+
import { closeSync, existsSync, mkdirSync, openSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
lastAssistantMessageOf,
|
|
17
|
+
parseHookPayloadObject,
|
|
18
|
+
promptOf,
|
|
19
|
+
sessionIdOf,
|
|
20
|
+
subagentTypeOf,
|
|
21
|
+
turnIdOf,
|
|
22
|
+
} from "#conversation/hookPayload.ts";
|
|
23
|
+
import { backfillAssistantMessages } from "#conversation/transcriptBackfill.ts";
|
|
24
|
+
import { type WritableDatabase, withTransaction } from "#db/database.ts";
|
|
25
|
+
import { mirrorForm } from "#mirror/mirrorForm.ts";
|
|
26
|
+
import {
|
|
27
|
+
decodeMetadata,
|
|
28
|
+
getRuntimeSession,
|
|
29
|
+
type RuntimeSessionRow,
|
|
30
|
+
resolveRuntimeSessionId,
|
|
31
|
+
upsertRuntimeSession,
|
|
32
|
+
} from "#mirror/runtimeSession.ts";
|
|
33
|
+
import { expandHome } from "#util/paths.ts";
|
|
34
|
+
import { pythonJsonDumps } from "#util/pyGenerators.ts";
|
|
35
|
+
import { codePointLength, sliceCodePoints } from "#util/pythonText.ts";
|
|
36
|
+
|
|
37
|
+
export interface LoggerDeps {
|
|
38
|
+
newId: () => string;
|
|
39
|
+
nowIso: () => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The DS7/DS8 close seam. Python's `end_conversation` writes `ended_at`
|
|
44
|
+
* deterministically, then runs two LLM-backed tails: extraction (conditional)
|
|
45
|
+
* and close-time metadata finalization (always, in a `finally`). Both are
|
|
46
|
+
* injected so slice A stays provider-free; slice C/D supply the real
|
|
47
|
+
* implementations behind the replay transport.
|
|
48
|
+
*/
|
|
49
|
+
export interface CloseHooks {
|
|
50
|
+
runExtraction?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
51
|
+
finalizeMetadata?: (db: WritableDatabase, conversationId: string) => Promise<void> | void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// PARITY NOTE for every call site below: Python's store upsert is asymmetric.
|
|
55
|
+
// `closed_at`/`metadata` use an `_UNSET` sentinel, so passing None means SET
|
|
56
|
+
// NULL. `conversation_id`/`interface`/`persona`/`journey`/`active` default to
|
|
57
|
+
// None meaning PRESERVE. The TS helper uses undefined=preserve, null=clear, so
|
|
58
|
+
// a Python `persona=None` maps to `persona: undefined` here — never `null`.
|
|
59
|
+
|
|
60
|
+
export interface SessionConversationOptions {
|
|
61
|
+
interface: string;
|
|
62
|
+
persona?: string | null;
|
|
63
|
+
journey?: string | null;
|
|
64
|
+
title?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Python `_generate_title`: first line, hard 80 cap, word-boundary 60 cut.
|
|
69
|
+
* Both cuts are by code point (Python slicing), so an emoji counts once.
|
|
70
|
+
*/
|
|
71
|
+
export function generateTitle(content: string): string {
|
|
72
|
+
const text = sliceCodePoints(content.trim().split("\n")[0] ?? "", 80);
|
|
73
|
+
if (codePointLength(text) <= 60) return text;
|
|
74
|
+
const cut = sliceCodePoints(text, 60);
|
|
75
|
+
const boundary = cut.lastIndexOf(" ");
|
|
76
|
+
return `${boundary === -1 ? cut : cut.slice(0, boundary)}...`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Python `ConversationService._clean_title`: collapse whitespace, bound length. */
|
|
80
|
+
export function cleanTitle(title: string): string {
|
|
81
|
+
const clean = title.trim().split(/\s+/).filter(Boolean).join(" ");
|
|
82
|
+
if (!clean) throw new Error("title is required");
|
|
83
|
+
if (clean.length > 160) throw new Error("title must be at most 160 characters");
|
|
84
|
+
return clean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function titleMetadata(
|
|
88
|
+
rawMetadata: string | null,
|
|
89
|
+
source: string,
|
|
90
|
+
status: string,
|
|
91
|
+
previousTitle: string | null,
|
|
92
|
+
): Record<string, unknown> {
|
|
93
|
+
const metadata = decodeMetadata(rawMetadata);
|
|
94
|
+
if (previousTitle && previousTitle !== metadata.previous_title) {
|
|
95
|
+
metadata.previous_title = previousTitle;
|
|
96
|
+
}
|
|
97
|
+
metadata.title_source = source;
|
|
98
|
+
metadata.title_status = status;
|
|
99
|
+
return metadata;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Python `ConversationService.set_provisional_title` for an exact id.
|
|
104
|
+
* (The Python service also resolves id prefixes; that fallback belongs to the
|
|
105
|
+
* title CLI operations and is not reachable from the logger paths, so it is
|
|
106
|
+
* deliberately not ported here.)
|
|
107
|
+
*/
|
|
108
|
+
export function setProvisionalTitle(
|
|
109
|
+
db: WritableDatabase,
|
|
110
|
+
conversationId: string,
|
|
111
|
+
title: string,
|
|
112
|
+
): void {
|
|
113
|
+
const clean = cleanTitle(title);
|
|
114
|
+
const row = db
|
|
115
|
+
.prepare("SELECT title, metadata FROM conversations WHERE id = ?")
|
|
116
|
+
.get(conversationId);
|
|
117
|
+
if (!row) throw new Error(`Conversation '${conversationId}' not found`);
|
|
118
|
+
const metadata = titleMetadata(
|
|
119
|
+
typeof row.metadata === "string" ? row.metadata : null,
|
|
120
|
+
"first_user",
|
|
121
|
+
"provisional",
|
|
122
|
+
typeof row.title === "string" ? row.title : null,
|
|
123
|
+
);
|
|
124
|
+
db.prepare("UPDATE conversations SET title = ?, metadata = ? WHERE id = ?").run(
|
|
125
|
+
clean,
|
|
126
|
+
pythonJsonDumps(metadata),
|
|
127
|
+
conversationId,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Python `RuntimeSessionService.get_or_create_conversation`: return the bound
|
|
133
|
+
* live conversation or create-and-bind atomically under BEGIN IMMEDIATE.
|
|
134
|
+
* Persona/journey come from the caller only — never from the session row.
|
|
135
|
+
*/
|
|
136
|
+
export function getOrCreateSessionConversation(
|
|
137
|
+
db: WritableDatabase,
|
|
138
|
+
sessionId: string,
|
|
139
|
+
options: SessionConversationOptions,
|
|
140
|
+
deps: LoggerDeps,
|
|
141
|
+
): string {
|
|
142
|
+
return withTransaction(db, () => {
|
|
143
|
+
const bound = db
|
|
144
|
+
.prepare("SELECT conversation_id FROM runtime_sessions WHERE session_id = ?")
|
|
145
|
+
.get(sessionId);
|
|
146
|
+
const boundId =
|
|
147
|
+
bound && typeof bound.conversation_id === "string" ? bound.conversation_id : null;
|
|
148
|
+
if (boundId) {
|
|
149
|
+
const existing = db.prepare("SELECT id FROM conversations WHERE id = ?").get(boundId);
|
|
150
|
+
if (existing) return boundId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const id = deps.newId();
|
|
154
|
+
const startedAt = deps.nowIso();
|
|
155
|
+
db.prepare(
|
|
156
|
+
`INSERT INTO conversations
|
|
157
|
+
(id, title, started_at, ended_at, interface, persona, journey, summary, tags, metadata)
|
|
158
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?, NULL, NULL, NULL)`,
|
|
159
|
+
).run(
|
|
160
|
+
id,
|
|
161
|
+
options.title ?? null,
|
|
162
|
+
startedAt,
|
|
163
|
+
options.interface,
|
|
164
|
+
options.persona ?? null,
|
|
165
|
+
options.journey ?? null,
|
|
166
|
+
);
|
|
167
|
+
// Mirrors the service's conflict clause exactly: it does NOT touch
|
|
168
|
+
// mirror_active, hook_injected, started_at, or metadata on update.
|
|
169
|
+
db.prepare(
|
|
170
|
+
`INSERT INTO runtime_sessions
|
|
171
|
+
(session_id, conversation_id, interface, mirror_active, persona, journey,
|
|
172
|
+
hook_injected, active, started_at, updated_at, closed_at, metadata)
|
|
173
|
+
VALUES (?, ?, ?, 0, ?, ?, 0, 1, ?, ?, NULL, NULL)
|
|
174
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
175
|
+
conversation_id = excluded.conversation_id,
|
|
176
|
+
interface = excluded.interface,
|
|
177
|
+
persona = excluded.persona,
|
|
178
|
+
journey = excluded.journey,
|
|
179
|
+
active = 1,
|
|
180
|
+
closed_at = NULL,
|
|
181
|
+
updated_at = excluded.updated_at`,
|
|
182
|
+
).run(
|
|
183
|
+
sessionId,
|
|
184
|
+
id,
|
|
185
|
+
options.interface,
|
|
186
|
+
options.persona ?? null,
|
|
187
|
+
options.journey ?? null,
|
|
188
|
+
startedAt,
|
|
189
|
+
startedAt,
|
|
190
|
+
);
|
|
191
|
+
return id;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function discardMarkerActive(session: RuntimeSessionRow | null): boolean {
|
|
196
|
+
return Boolean(decodeMetadata(session?.metadata ?? null).discard_current_conversation);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* What a message row may carry beside its text. The runtime's turn id (US8)
|
|
201
|
+
* is the one field today: written by the prompt and Stop hooks, read by the
|
|
202
|
+
* reply rule, and NULL for every message logged another way, as before.
|
|
203
|
+
*/
|
|
204
|
+
export interface MessageMetadata {
|
|
205
|
+
readonly turn_id?: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface LogMessageOptions {
|
|
209
|
+
interface: string;
|
|
210
|
+
metadata?: MessageMetadata | null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function addMessage(
|
|
214
|
+
db: WritableDatabase,
|
|
215
|
+
conversationId: string,
|
|
216
|
+
role: "user" | "assistant",
|
|
217
|
+
content: string,
|
|
218
|
+
deps: LoggerDeps,
|
|
219
|
+
metadata: MessageMetadata | null = null,
|
|
220
|
+
): void {
|
|
221
|
+
db.prepare(
|
|
222
|
+
`INSERT INTO messages (id, conversation_id, role, content, created_at, token_count, metadata)
|
|
223
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
|
224
|
+
).run(
|
|
225
|
+
deps.newId(),
|
|
226
|
+
conversationId,
|
|
227
|
+
role,
|
|
228
|
+
content,
|
|
229
|
+
deps.nowIso(),
|
|
230
|
+
metadata ? pythonJsonDumps(metadata) : null,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Python `log_user_message`: discard-marker reset, bind, title-on-first, append. */
|
|
235
|
+
export function logUserMessage(
|
|
236
|
+
db: WritableDatabase,
|
|
237
|
+
sessionId: string,
|
|
238
|
+
content: string,
|
|
239
|
+
options: LogMessageOptions,
|
|
240
|
+
deps: LoggerDeps,
|
|
241
|
+
): void {
|
|
242
|
+
let existing = getRuntimeSession(db, sessionId);
|
|
243
|
+
if (discardMarkerActive(existing)) {
|
|
244
|
+
upsertRuntimeSession(
|
|
245
|
+
db,
|
|
246
|
+
sessionId,
|
|
247
|
+
{ metadata: null, active: true, closedAt: null },
|
|
248
|
+
deps.nowIso(),
|
|
249
|
+
);
|
|
250
|
+
existing = getRuntimeSession(db, sessionId);
|
|
251
|
+
}
|
|
252
|
+
const isNew = existing === null || existing.conversationId === null;
|
|
253
|
+
const conversationId = getOrCreateSessionConversation(
|
|
254
|
+
db,
|
|
255
|
+
sessionId,
|
|
256
|
+
{ interface: options.interface },
|
|
257
|
+
deps,
|
|
258
|
+
);
|
|
259
|
+
upsertRuntimeSession(
|
|
260
|
+
db,
|
|
261
|
+
sessionId,
|
|
262
|
+
{ interface: options.interface, active: true, closedAt: null },
|
|
263
|
+
deps.nowIso(),
|
|
264
|
+
);
|
|
265
|
+
if (isNew) {
|
|
266
|
+
setProvisionalTitle(db, conversationId, generateTitle(content));
|
|
267
|
+
}
|
|
268
|
+
addMessage(db, conversationId, "user", content, deps, options.metadata ?? null);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Python `log_assistant_message`: silent no-op while the discard marker holds. */
|
|
272
|
+
export function logAssistantMessage(
|
|
273
|
+
db: WritableDatabase,
|
|
274
|
+
sessionId: string,
|
|
275
|
+
content: string,
|
|
276
|
+
options: LogMessageOptions,
|
|
277
|
+
deps: LoggerDeps,
|
|
278
|
+
): void {
|
|
279
|
+
const existing = getRuntimeSession(db, sessionId);
|
|
280
|
+
if (discardMarkerActive(existing)) return;
|
|
281
|
+
const conversationId = getOrCreateSessionConversation(
|
|
282
|
+
db,
|
|
283
|
+
sessionId,
|
|
284
|
+
{ interface: options.interface },
|
|
285
|
+
deps,
|
|
286
|
+
);
|
|
287
|
+
addMessage(db, conversationId, "assistant", content, deps, options.metadata ?? null);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Python `ConversationService.start_conversation`: always a fresh row. */
|
|
291
|
+
function startConversation(
|
|
292
|
+
db: WritableDatabase,
|
|
293
|
+
options: SessionConversationOptions,
|
|
294
|
+
deps: LoggerDeps,
|
|
295
|
+
): string {
|
|
296
|
+
const id = deps.newId();
|
|
297
|
+
db.prepare(
|
|
298
|
+
`INSERT INTO conversations
|
|
299
|
+
(id, title, started_at, ended_at, interface, persona, journey, summary, tags, metadata)
|
|
300
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?, NULL, NULL, NULL)`,
|
|
301
|
+
).run(
|
|
302
|
+
id,
|
|
303
|
+
options.title ?? null,
|
|
304
|
+
deps.nowIso(),
|
|
305
|
+
options.interface,
|
|
306
|
+
options.persona ?? null,
|
|
307
|
+
options.journey ?? null,
|
|
308
|
+
);
|
|
309
|
+
return id;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Python `ConversationService.end_conversation`: deterministic close + tails.
|
|
314
|
+
*
|
|
315
|
+
* Async since CV22.DS7.US10 slice C′: both tails are genuinely asynchronous in
|
|
316
|
+
* TypeScript (they reach the LLM transport), and awaiting them here keeps ONE
|
|
317
|
+
* authority for the ordering contract — `ended_at` first, extraction second,
|
|
318
|
+
* finalization in a `finally`. Duplicating that contract in a second async
|
|
319
|
+
* close path would let the two drift on exactly the property that must not.
|
|
320
|
+
*/
|
|
321
|
+
export async function endConversation(
|
|
322
|
+
db: WritableDatabase,
|
|
323
|
+
conversationId: string,
|
|
324
|
+
options: { extract: boolean },
|
|
325
|
+
deps: LoggerDeps,
|
|
326
|
+
hooks: CloseHooks = {},
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
db.prepare("UPDATE conversations SET ended_at = ? WHERE id = ?").run(
|
|
329
|
+
deps.nowIso(),
|
|
330
|
+
conversationId,
|
|
331
|
+
);
|
|
332
|
+
try {
|
|
333
|
+
if (options.extract) await hooks.runExtraction?.(db, conversationId);
|
|
334
|
+
} finally {
|
|
335
|
+
// Python finalizes in a `finally`: the orphan is closed and non-manual
|
|
336
|
+
// metadata finalized even when extraction fails.
|
|
337
|
+
await hooks.finalizeMetadata?.(db, conversationId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Python `switch_conversation`: close the old conversation, bind a new one. */
|
|
342
|
+
export async function switchConversation(
|
|
343
|
+
db: WritableDatabase,
|
|
344
|
+
sessionId: string | null,
|
|
345
|
+
options: { persona?: string | null; journey?: string | null; envSessionId?: string | null },
|
|
346
|
+
deps: LoggerDeps,
|
|
347
|
+
hooks: CloseHooks = {},
|
|
348
|
+
): Promise<string | null> {
|
|
349
|
+
const resolved = resolveRuntimeSessionId(db, sessionId, options.envSessionId ?? null);
|
|
350
|
+
if (!resolved) return null;
|
|
351
|
+
|
|
352
|
+
const runtimeSession = getRuntimeSession(db, resolved);
|
|
353
|
+
const oldConversationId = runtimeSession?.conversationId ?? null;
|
|
354
|
+
if (oldConversationId) {
|
|
355
|
+
await endConversation(db, oldConversationId, { extract: true }, deps, hooks);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Python truthiness: an empty interface also falls back to claude_code.
|
|
359
|
+
const existingInterface = runtimeSession?.interface;
|
|
360
|
+
const interfaceName = existingInterface ? existingInterface : "claude_code";
|
|
361
|
+
const newConversationId = startConversation(
|
|
362
|
+
db,
|
|
363
|
+
{
|
|
364
|
+
interface: interfaceName,
|
|
365
|
+
persona: options.persona ?? null,
|
|
366
|
+
journey: options.journey ?? null,
|
|
367
|
+
},
|
|
368
|
+
deps,
|
|
369
|
+
);
|
|
370
|
+
upsertRuntimeSession(
|
|
371
|
+
db,
|
|
372
|
+
resolved,
|
|
373
|
+
{
|
|
374
|
+
conversationId: newConversationId,
|
|
375
|
+
interface: interfaceName,
|
|
376
|
+
persona: options.persona ?? undefined,
|
|
377
|
+
journey: options.journey ?? undefined,
|
|
378
|
+
active: true,
|
|
379
|
+
closedAt: null,
|
|
380
|
+
},
|
|
381
|
+
deps.nowIso(),
|
|
382
|
+
);
|
|
383
|
+
return newConversationId;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Python `end_session`: close the bound conversation and deactivate. */
|
|
387
|
+
export async function endSession(
|
|
388
|
+
db: WritableDatabase,
|
|
389
|
+
sessionId: string,
|
|
390
|
+
options: { extract: boolean },
|
|
391
|
+
deps: LoggerDeps,
|
|
392
|
+
hooks: CloseHooks = {},
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
const runtimeSession = getRuntimeSession(db, sessionId);
|
|
395
|
+
const conversationId = runtimeSession?.conversationId ?? null;
|
|
396
|
+
if (!conversationId) return;
|
|
397
|
+
await endConversation(db, conversationId, { extract: options.extract }, deps, hooks);
|
|
398
|
+
upsertRuntimeSession(db, sessionId, { active: false, closedAt: deps.nowIso() }, deps.nowIso());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Python `ConversationService.delete_conversations` for one resolved id:
|
|
403
|
+
* extracted memories survive with a nulled FK rather than cascading away.
|
|
404
|
+
*/
|
|
405
|
+
function deleteConversation(db: WritableDatabase, conversationId: string): void {
|
|
406
|
+
db.prepare("DELETE FROM messages WHERE conversation_id = ?").run(conversationId);
|
|
407
|
+
db.prepare("DELETE FROM conversation_embeddings WHERE conversation_id = ?").run(conversationId);
|
|
408
|
+
db.prepare("UPDATE memories SET conversation_id = NULL WHERE conversation_id = ?").run(
|
|
409
|
+
conversationId,
|
|
410
|
+
);
|
|
411
|
+
db.prepare("UPDATE llm_calls SET conversation_id = NULL WHERE conversation_id = ?").run(
|
|
412
|
+
conversationId,
|
|
413
|
+
);
|
|
414
|
+
db.prepare("UPDATE runtime_sessions SET conversation_id = NULL WHERE conversation_id = ?").run(
|
|
415
|
+
conversationId,
|
|
416
|
+
);
|
|
417
|
+
db.prepare("DELETE FROM conversations WHERE id = ?").run(conversationId);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Python `discard_current_conversation`: delete and mark the session discarded. */
|
|
421
|
+
export function discardCurrentConversation(
|
|
422
|
+
db: WritableDatabase,
|
|
423
|
+
sessionId: string | null,
|
|
424
|
+
options: { interface: string },
|
|
425
|
+
deps: LoggerDeps,
|
|
426
|
+
): string | null {
|
|
427
|
+
let resolved = sessionId;
|
|
428
|
+
if (resolved === null) {
|
|
429
|
+
const row = db
|
|
430
|
+
.prepare(
|
|
431
|
+
`SELECT session_id FROM runtime_sessions
|
|
432
|
+
WHERE active = 1 AND interface = ?
|
|
433
|
+
ORDER BY updated_at DESC`,
|
|
434
|
+
)
|
|
435
|
+
.get(options.interface);
|
|
436
|
+
resolved = row ? String(row.session_id) : null;
|
|
437
|
+
}
|
|
438
|
+
if (resolved === null) return null;
|
|
439
|
+
|
|
440
|
+
const runtimeSession = getRuntimeSession(db, resolved);
|
|
441
|
+
if (!runtimeSession || runtimeSession.conversationId === null) return null;
|
|
442
|
+
const conversationId = runtimeSession.conversationId;
|
|
443
|
+
|
|
444
|
+
withTransaction(db, () => {
|
|
445
|
+
deleteConversation(db, conversationId);
|
|
446
|
+
return null;
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const existingInterface = runtimeSession.interface;
|
|
450
|
+
upsertRuntimeSession(
|
|
451
|
+
db,
|
|
452
|
+
resolved,
|
|
453
|
+
{
|
|
454
|
+
// Python passes conversation_id=None, which the store PRESERVES; the
|
|
455
|
+
// delete above already nulled it, so preserve and clear coincide here.
|
|
456
|
+
interface: existingInterface ? existingInterface : options.interface,
|
|
457
|
+
active: false,
|
|
458
|
+
metadata: pythonJsonDumps({ discard_current_conversation: true }),
|
|
459
|
+
},
|
|
460
|
+
deps.nowIso(),
|
|
461
|
+
);
|
|
462
|
+
return conversationId;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// --- Runtime hook entries -------------------------------------------------
|
|
466
|
+
//
|
|
467
|
+
// `hook_user_prompt` and `hook_session_end` are the hot path: they run on
|
|
468
|
+
// every message and every session close. Python wraps both in
|
|
469
|
+
// `except Exception: pass` and always exits 0, so a logging failure can never
|
|
470
|
+
// take down the runtime. These ports return a described outcome instead of
|
|
471
|
+
// exiting, keeping stdin/env at the CLI edge and the behavior testable; the
|
|
472
|
+
// CLI maps every outcome to exit code 0.
|
|
473
|
+
|
|
474
|
+
export type UserPromptHookOutcome =
|
|
475
|
+
| { action: "muted" }
|
|
476
|
+
| {
|
|
477
|
+
action: "skipped";
|
|
478
|
+
reason:
|
|
479
|
+
| "malformed_payload"
|
|
480
|
+
| "missing_session"
|
|
481
|
+
| "missing_prompt"
|
|
482
|
+
| "slash_command"
|
|
483
|
+
| "bare_mirror_form";
|
|
484
|
+
}
|
|
485
|
+
| { action: "logged"; sessionId: string }
|
|
486
|
+
| { action: "failed"; error: string };
|
|
487
|
+
|
|
488
|
+
export type StopHookOutcome =
|
|
489
|
+
| { action: "muted" }
|
|
490
|
+
| {
|
|
491
|
+
action: "skipped";
|
|
492
|
+
reason:
|
|
493
|
+
| "malformed_payload"
|
|
494
|
+
| "missing_session"
|
|
495
|
+
| "subagent"
|
|
496
|
+
| "empty_message"
|
|
497
|
+
| "discarded"
|
|
498
|
+
| "already_answered"
|
|
499
|
+
// Expected: the turn's prompt was a command, which history skips.
|
|
500
|
+
| "command_turn"
|
|
501
|
+
// Expected: the Stop names no turn (Grok's at session end).
|
|
502
|
+
| "session_event"
|
|
503
|
+
// Unexplained: the turn's prompt was neither logged nor skipped as a
|
|
504
|
+
// command -- the reply is lost. The only skip the caller reports.
|
|
505
|
+
| "nothing_waiting";
|
|
506
|
+
}
|
|
507
|
+
| { action: "logged"; sessionId: string; pairedBy: "turn_id" | "last_message" }
|
|
508
|
+
| { action: "failed"; error: string };
|
|
509
|
+
|
|
510
|
+
export interface HookLoggingOptions {
|
|
511
|
+
mirrorHome: string;
|
|
512
|
+
/** The runtime the hook detected (`claude_code`, `codex`, `grok_build`). */
|
|
513
|
+
interface?: string;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export type SessionEndHookOutcome =
|
|
517
|
+
| { action: "skipped"; reason: "malformed_payload" | "nothing_to_do" }
|
|
518
|
+
| { action: "ended"; sessionId: string; transcriptPath: string | null; backfilled: boolean }
|
|
519
|
+
| { action: "backfilled"; transcriptPath: string }
|
|
520
|
+
| { action: "failed"; error: string };
|
|
521
|
+
|
|
522
|
+
const parsePayload = parseHookPayloadObject;
|
|
523
|
+
|
|
524
|
+
function payloadString(payload: Record<string, unknown>, key: string): string {
|
|
525
|
+
const value = payload[key];
|
|
526
|
+
return typeof value === "string" ? value : "";
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function turnMetadata(turnId: string): MessageMetadata | null {
|
|
530
|
+
return turnId ? { turn_id: turnId } : null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The session-metadata key that names the last turn the prompt hook skipped as a
|
|
535
|
+
* command, so the Stop hook can tell that turn's reply -- expected to go
|
|
536
|
+
* unrecorded -- from a reply lost because its prompt never reached Mirror.
|
|
537
|
+
*/
|
|
538
|
+
const SKIPPED_TURN_KEY = "skipped_turn_id";
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Record the turn a command prompt occupied. `runtime_sessions.metadata` is a
|
|
542
|
+
* shared JSON object (the discard marker, the operating mode, Soul, Explorer,
|
|
543
|
+
* and Builder keep keys there), so the key is merged in, never written over.
|
|
544
|
+
*/
|
|
545
|
+
function recordSkippedTurn(
|
|
546
|
+
db: WritableDatabase,
|
|
547
|
+
sessionId: string,
|
|
548
|
+
turnId: string,
|
|
549
|
+
deps: LoggerDeps,
|
|
550
|
+
): void {
|
|
551
|
+
if (!turnId) return;
|
|
552
|
+
const metadata = decodeMetadata(getRuntimeSession(db, sessionId)?.metadata ?? null);
|
|
553
|
+
metadata[SKIPPED_TURN_KEY] = turnId;
|
|
554
|
+
upsertRuntimeSession(db, sessionId, { metadata: pythonJsonDumps(metadata) }, deps.nowIso());
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Python `hook_user_prompt`: mute gate, slash-command skip, never throw.
|
|
559
|
+
*
|
|
560
|
+
* Two additions since CV21.DS2.US8. The session's label is stamped on every
|
|
561
|
+
* prompt, skipped or not, before anything else can create the conversation:
|
|
562
|
+
* the Mirror Mode path creates one with `session?.interface ?? "claude_code"`,
|
|
563
|
+
* and `guessRuntimeSessionId` ignores a session row with no interface, so a
|
|
564
|
+
* Grok session whose first turn was a Mirror turn used to be labelled
|
|
565
|
+
* `claude_code` and bound to someone else's session (DS12.TS4 L7, TS2 L9).
|
|
566
|
+
* And a Mirror turn is a conversation turn: its question is logged without
|
|
567
|
+
* the form, where every other slash command stays a command.
|
|
568
|
+
*/
|
|
569
|
+
export function handleUserPromptHook(
|
|
570
|
+
db: WritableDatabase,
|
|
571
|
+
rawPayload: string,
|
|
572
|
+
options: HookLoggingOptions,
|
|
573
|
+
deps: LoggerDeps,
|
|
574
|
+
): UserPromptHookOutcome {
|
|
575
|
+
try {
|
|
576
|
+
const payload = parsePayload(rawPayload);
|
|
577
|
+
if (!payload) return { action: "skipped", reason: "malformed_payload" };
|
|
578
|
+
|
|
579
|
+
const sessionId = sessionIdOf(payload);
|
|
580
|
+
const prompt = promptOf(payload);
|
|
581
|
+
if (!sessionId) return { action: "skipped", reason: "missing_session" };
|
|
582
|
+
|
|
583
|
+
const interfaceName = options.interface ?? "claude_code";
|
|
584
|
+
upsertRuntimeSession(db, sessionId, { interface: interfaceName }, deps.nowIso());
|
|
585
|
+
|
|
586
|
+
if (isMuted(options.mirrorHome)) return { action: "muted" };
|
|
587
|
+
if (!prompt) return { action: "skipped", reason: "missing_prompt" };
|
|
588
|
+
|
|
589
|
+
const mirror = mirrorForm(prompt);
|
|
590
|
+
let content = prompt;
|
|
591
|
+
if (mirror) {
|
|
592
|
+
if (!mirror.query) {
|
|
593
|
+
recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
|
|
594
|
+
return { action: "skipped", reason: "bare_mirror_form" };
|
|
595
|
+
}
|
|
596
|
+
content = mirror.query;
|
|
597
|
+
} else if (prompt.startsWith("/")) {
|
|
598
|
+
// Slash commands never enter conversation history, and neither does
|
|
599
|
+
// their reply: the turn is recorded so the Stop hook knows why.
|
|
600
|
+
recordSkippedTurn(db, sessionId, turnIdOf(payload), deps);
|
|
601
|
+
return { action: "skipped", reason: "slash_command" };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
logUserMessage(
|
|
605
|
+
db,
|
|
606
|
+
sessionId,
|
|
607
|
+
content,
|
|
608
|
+
{ interface: interfaceName, metadata: turnMetadata(turnIdOf(payload)) },
|
|
609
|
+
deps,
|
|
610
|
+
);
|
|
611
|
+
return { action: "logged", sessionId };
|
|
612
|
+
} catch (error) {
|
|
613
|
+
// Python swallows everything here; the runtime must survive a logging fault.
|
|
614
|
+
return { action: "failed", error: error instanceof Error ? error.message : String(error) };
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
interface MessageRow {
|
|
619
|
+
rowid: number;
|
|
620
|
+
role: string;
|
|
621
|
+
created_at: string;
|
|
622
|
+
metadata: string | null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The conversation's last message, in the order the rows were written. */
|
|
626
|
+
function lastMessage(db: WritableDatabase, conversationId: string): MessageRow | null {
|
|
627
|
+
const row = db
|
|
628
|
+
.prepare(
|
|
629
|
+
`SELECT rowid, role, created_at, metadata FROM messages
|
|
630
|
+
WHERE conversation_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
|
631
|
+
)
|
|
632
|
+
.get(conversationId);
|
|
633
|
+
return (row as MessageRow | undefined) ?? null;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** The latest logged prompt of the turn, if the turn's prompt was logged. */
|
|
637
|
+
function promptOfTurn(
|
|
638
|
+
db: WritableDatabase,
|
|
639
|
+
conversationId: string,
|
|
640
|
+
turnId: string,
|
|
641
|
+
): MessageRow | null {
|
|
642
|
+
const row = db
|
|
643
|
+
.prepare(
|
|
644
|
+
`SELECT rowid, role, created_at, metadata FROM messages
|
|
645
|
+
WHERE conversation_id = ? AND role = 'user' AND json_extract(metadata, '$.turn_id') = ?
|
|
646
|
+
ORDER BY created_at DESC, rowid DESC LIMIT 1`,
|
|
647
|
+
)
|
|
648
|
+
.get(conversationId, turnId);
|
|
649
|
+
return (row as MessageRow | undefined) ?? null;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Whether the turn already has its reply: an assistant row carrying the same
|
|
654
|
+
* turn id, after the prompt. Only a Stop reply carries one, so an assistant row
|
|
655
|
+
* written another way -- a summary `mirror mirror log` appended by hand, a
|
|
656
|
+
* transcript backfill -- never stands in for the turn's answer.
|
|
657
|
+
*/
|
|
658
|
+
function answeredAfter(
|
|
659
|
+
db: WritableDatabase,
|
|
660
|
+
conversationId: string,
|
|
661
|
+
prompt: MessageRow,
|
|
662
|
+
turnId: string,
|
|
663
|
+
): boolean {
|
|
664
|
+
const row = db
|
|
665
|
+
.prepare(
|
|
666
|
+
`SELECT 1 FROM messages
|
|
667
|
+
WHERE conversation_id = ? AND role = 'assistant'
|
|
668
|
+
AND json_extract(metadata, '$.turn_id') = ?
|
|
669
|
+
AND (created_at > ? OR (created_at = ? AND rowid > ?)) LIMIT 1`,
|
|
670
|
+
)
|
|
671
|
+
.get(conversationId, turnId, prompt.created_at, prompt.created_at, prompt.rowid);
|
|
672
|
+
return row !== undefined;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function turnIdOfRow(row: MessageRow): string {
|
|
676
|
+
const value = decodeMetadata(row.metadata).turn_id;
|
|
677
|
+
return typeof value === "string" ? value : "";
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The Stop hook (CV21.DS2.US8): log the turn's final answer beside its prompt.
|
|
682
|
+
*
|
|
683
|
+
* A reply is logged when it answers a logged prompt, and never otherwise:
|
|
684
|
+
*
|
|
685
|
+
* * by turn id, when the payload carries one and a logged prompt carries the
|
|
686
|
+
* same -- unless a reply with that turn id already follows it, which is a
|
|
687
|
+
* repeat Stop (a Stop re-fired after another hook blocked). An assistant
|
|
688
|
+
* row without the turn id (a hand-logged summary) does not count;
|
|
689
|
+
* * else by position, when the conversation's last message is a logged
|
|
690
|
+
* prompt -- unless both sides carry a turn id and they differ, which means
|
|
691
|
+
* this turn's prompt was a command and an older prompt is waiting because
|
|
692
|
+
* its own Stop was lost; attaching the new answer to the old question
|
|
693
|
+
* would record a conversation that never happened.
|
|
694
|
+
*
|
|
695
|
+
* The handler never creates a conversation. When nothing is waiting, it says
|
|
696
|
+
* why: `command_turn` when the prompt hook skipped this turn as a command,
|
|
697
|
+
* `session_event` when the Stop names no turn (Grok's at session end), and
|
|
698
|
+
* `nothing_waiting` only when nothing explains it -- the turn's prompt never
|
|
699
|
+
* reached Mirror, the async race the plan names, a reply lost. The caller
|
|
700
|
+
* writes a `hooks.log` line for that one alone: `runtime diagnose` reads every
|
|
701
|
+
* line there as a failure, and an expected skip is not one (Debt Review,
|
|
702
|
+
* 2026-10-13).
|
|
703
|
+
*/
|
|
704
|
+
export function handleStopHook(
|
|
705
|
+
db: WritableDatabase,
|
|
706
|
+
rawPayload: string,
|
|
707
|
+
options: HookLoggingOptions,
|
|
708
|
+
deps: LoggerDeps,
|
|
709
|
+
): StopHookOutcome {
|
|
710
|
+
try {
|
|
711
|
+
const payload = parsePayload(rawPayload);
|
|
712
|
+
if (!payload) return { action: "skipped", reason: "malformed_payload" };
|
|
713
|
+
|
|
714
|
+
const sessionId = sessionIdOf(payload);
|
|
715
|
+
if (!sessionId) return { action: "skipped", reason: "missing_session" };
|
|
716
|
+
if (isMuted(options.mirrorHome)) return { action: "muted" };
|
|
717
|
+
if (subagentTypeOf(payload)) return { action: "skipped", reason: "subagent" };
|
|
718
|
+
|
|
719
|
+
const message = lastAssistantMessageOf(payload);
|
|
720
|
+
if (!message.trim()) return { action: "skipped", reason: "empty_message" };
|
|
721
|
+
|
|
722
|
+
const session = getRuntimeSession(db, sessionId);
|
|
723
|
+
if (discardMarkerActive(session)) return { action: "skipped", reason: "discarded" };
|
|
724
|
+
const turnId = turnIdOf(payload);
|
|
725
|
+
const nothingWaiting = (): StopHookOutcome => {
|
|
726
|
+
if (!turnId) return { action: "skipped", reason: "session_event" };
|
|
727
|
+
if (decodeMetadata(session?.metadata ?? null)[SKIPPED_TURN_KEY] === turnId) {
|
|
728
|
+
return { action: "skipped", reason: "command_turn" };
|
|
729
|
+
}
|
|
730
|
+
return { action: "skipped", reason: "nothing_waiting" };
|
|
731
|
+
};
|
|
732
|
+
const conversationId = session?.conversationId ?? null;
|
|
733
|
+
if (!conversationId) return nothingWaiting();
|
|
734
|
+
|
|
735
|
+
const interfaceName = options.interface ?? session?.interface ?? "claude_code";
|
|
736
|
+
const last = lastMessage(db, conversationId);
|
|
737
|
+
|
|
738
|
+
if (turnId) {
|
|
739
|
+
const prompt = promptOfTurn(db, conversationId, turnId);
|
|
740
|
+
if (prompt) {
|
|
741
|
+
if (answeredAfter(db, conversationId, prompt, turnId)) {
|
|
742
|
+
return { action: "skipped", reason: "already_answered" };
|
|
743
|
+
}
|
|
744
|
+
logAssistantMessage(
|
|
745
|
+
db,
|
|
746
|
+
sessionId,
|
|
747
|
+
message,
|
|
748
|
+
{ interface: interfaceName, metadata: turnMetadata(turnId) },
|
|
749
|
+
deps,
|
|
750
|
+
);
|
|
751
|
+
return { action: "logged", sessionId, pairedBy: "turn_id" };
|
|
752
|
+
}
|
|
753
|
+
if (last && last.role === "user" && !turnIdOfRow(last)) {
|
|
754
|
+
logAssistantMessage(
|
|
755
|
+
db,
|
|
756
|
+
sessionId,
|
|
757
|
+
message,
|
|
758
|
+
{ interface: interfaceName, metadata: turnMetadata(turnId) },
|
|
759
|
+
deps,
|
|
760
|
+
);
|
|
761
|
+
return { action: "logged", sessionId, pairedBy: "last_message" };
|
|
762
|
+
}
|
|
763
|
+
return nothingWaiting();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (last && last.role === "user") {
|
|
767
|
+
logAssistantMessage(db, sessionId, message, { interface: interfaceName }, deps);
|
|
768
|
+
return { action: "logged", sessionId, pairedBy: "last_message" };
|
|
769
|
+
}
|
|
770
|
+
return nothingWaiting();
|
|
771
|
+
} catch (error) {
|
|
772
|
+
return { action: "failed", error: error instanceof Error ? error.message : String(error) };
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Python `hook_session_end`: end the session when the payload names one, then
|
|
778
|
+
* backfill assistant turns from the transcript when it exists -- in that
|
|
779
|
+
* order, so the just-ended conversation's `ended_at` bounds the backfill.
|
|
780
|
+
*
|
|
781
|
+
* The two halves are independent: an empty `session_id` with a transcript
|
|
782
|
+
* still backfills (the session-less route, slice E), and a session without a
|
|
783
|
+
* transcript still ends. Everything is swallowed; the hook always exits 0.
|
|
784
|
+
*/
|
|
785
|
+
export async function handleSessionEndHook(
|
|
786
|
+
db: WritableDatabase,
|
|
787
|
+
rawPayload: string,
|
|
788
|
+
options: { mirrorHome: string; claudeProjectDir: string | null; homeDir: string },
|
|
789
|
+
deps: LoggerDeps,
|
|
790
|
+
hooks: CloseHooks = {},
|
|
791
|
+
): Promise<SessionEndHookOutcome> {
|
|
792
|
+
try {
|
|
793
|
+
const payload = parsePayload(rawPayload);
|
|
794
|
+
if (!payload) return { action: "skipped", reason: "malformed_payload" };
|
|
795
|
+
|
|
796
|
+
const sessionId = payloadString(payload, "session_id");
|
|
797
|
+
if (sessionId) await endSession(db, sessionId, { extract: true }, deps, hooks);
|
|
798
|
+
|
|
799
|
+
const transcriptPath = resolveTranscriptPath(
|
|
800
|
+
payloadString(payload, "transcript_path"),
|
|
801
|
+
sessionId,
|
|
802
|
+
options.claudeProjectDir,
|
|
803
|
+
options.homeDir,
|
|
804
|
+
);
|
|
805
|
+
const backfilled = transcriptPath !== null && existsSync(transcriptPath);
|
|
806
|
+
if (backfilled) backfillAssistantMessages(db, transcriptPath, deps);
|
|
807
|
+
|
|
808
|
+
if (sessionId) return { action: "ended", sessionId, transcriptPath, backfilled };
|
|
809
|
+
if (backfilled) return { action: "backfilled", transcriptPath };
|
|
810
|
+
return { action: "skipped", reason: "nothing_to_do" };
|
|
811
|
+
} catch (error) {
|
|
812
|
+
return { action: "failed", error: error instanceof Error ? error.message : String(error) };
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Python's transcript fallback: an explicit payload path wins; otherwise
|
|
818
|
+
* derive `~/.claude/projects/<project-hash>/<session>.jsonl`, where the hash
|
|
819
|
+
* is the project dir with leading slashes stripped and `/` replaced by `-`.
|
|
820
|
+
*/
|
|
821
|
+
export function resolveTranscriptPath(
|
|
822
|
+
payloadPath: string,
|
|
823
|
+
sessionId: string,
|
|
824
|
+
claudeProjectDir: string | null,
|
|
825
|
+
homeDir: string,
|
|
826
|
+
): string | null {
|
|
827
|
+
if (payloadPath) return payloadPath;
|
|
828
|
+
if (!claudeProjectDir) return null;
|
|
829
|
+
const projectHash = claudeProjectDir.replace(/^\/+/, "").replaceAll("/", "-");
|
|
830
|
+
return join(homeDir, ".claude", "projects", projectHash, `${sessionId}.jsonl`);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function muteFlagPath(mirrorHome: string): string {
|
|
834
|
+
return join(expandHome(mirrorHome), "mute");
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** Python `is_muted` with an explicit mirror home. */
|
|
838
|
+
export function isMuted(mirrorHome: string): boolean {
|
|
839
|
+
return existsSync(muteFlagPath(mirrorHome));
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Python `set_mute`: touch (no truncate) or unlink with missing_ok. */
|
|
843
|
+
export function setMute(on: boolean, mirrorHome: string): void {
|
|
844
|
+
const path = muteFlagPath(mirrorHome);
|
|
845
|
+
if (on) {
|
|
846
|
+
mkdirSync(expandHome(mirrorHome), { recursive: true });
|
|
847
|
+
closeSync(openSync(path, "a"));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
rmSync(path, { force: true });
|
|
851
|
+
}
|