@openchambery/web 2.0.0-beta.9 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/assets/{AgentAvatar-CzTtVMnI.js → AgentAvatar-DGyF86jy.js} +1 -1
- package/dist/assets/AssistantView-ByGdaGiF.js +3 -0
- package/dist/assets/{DiagramView-oeXj4YHn.js → DiagramView-PB0pBILb.js} +1 -1
- package/dist/assets/{FadeInOnReveal-DCqrQkkz.js → FadeInOnReveal-B_a083XJ.js} +1 -1
- package/dist/assets/Icon-BE2hkaYc.js +1 -0
- package/dist/assets/MarkdownRenderer-H1MIHXzt.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-CahUctxC.js → MarkdownRendererImpl-CZ8GeckU.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-5JUaTSdl.js → MarkstreamRendererImpl-BKE6YZ2N.js} +1 -1
- package/dist/assets/MobileDetailNavigation-D9KkIrWy.js +1 -0
- package/dist/assets/{MobileShareBridge-yCN95dYg.js → MobileShareBridge-1yCjq-60.js} +3 -3
- package/dist/assets/MobileSurface-Dqn3OPVX.js +2 -0
- package/dist/assets/{MultiRunWindow-C0HqMFpp.js → MultiRunWindow-YCFM6or1.js} +1 -1
- package/dist/assets/{OnboardingScreen-O4cGdz5p.js → OnboardingScreen-CTmsNR65.js} +1 -1
- package/dist/assets/{OverlayScrollbar-CRfGtcmG.js → OverlayScrollbar-CVgW_Z9B.js} +1 -1
- package/dist/assets/SettingsView-CbiuXtYG.js +25 -0
- package/dist/assets/{SettingsWindow-B_d5Aqq3.js → SettingsWindow-jOZ7L6K8.js} +1 -1
- package/dist/assets/{TerminalView-DdNQQb8W.js → TerminalView-CI-KsDdH.js} +1 -1
- package/dist/assets/{ToolOutputDialog-4DC5OE1J.js → ToolOutputDialog-izZjgkkg.js} +1 -1
- package/dist/assets/appThemeRegistry-MiWlVk4e.js +1 -0
- package/dist/assets/{assistant-attachment-cache-DphJleHr.js → assistant-attachment-cache-sG8YQsVs.js} +1 -1
- package/dist/assets/{button-DIIrsMjW.js → button-CcOoDLE1.js} +1 -1
- package/dist/assets/{collapsible-CnvUeYst.js → collapsible-CSa0Ndgm.js} +1 -1
- package/dist/assets/{context-menu-BGBUpZEQ.js → context-menu-DoTwmMb5.js} +1 -1
- package/dist/assets/{desktopHosts-JGFqhlaM.js → desktopHosts-BsbquPqS.js} +1 -1
- package/dist/assets/desktopRelayRestore-yyvjztgj.js +1 -0
- package/dist/assets/{diagram-rvn7_qBM.js → diagram-CZKDkADZ.js} +1 -1
- package/dist/assets/dialog-Csvrld51.js +1 -0
- package/dist/assets/dropdown-menu.styles-ayLWk2gf.js +1 -0
- package/dist/assets/{es-Dsmc0A_3.js → es-BBbon7SL.js} +4 -4
- package/dist/assets/fileMentionTouchSelection-Bn0f83D3.js +2 -0
- package/dist/assets/{fileQueries-DN6gbLhM.js → fileQueries-B0hhXD86.js} +1 -1
- package/dist/assets/{fr-C_ZlZDI2.js → fr-BsKj2CTi.js} +4 -4
- package/dist/assets/{gitApi-da8etTTe.js → gitApi-DpwH7xTg.js} +4 -4
- package/dist/assets/{imageSource-BMztRu0c.js → imageSource-BpUsiwFd.js} +1 -1
- package/dist/assets/{input-BVu-iykl.js → input-C6Y7mytw.js} +1 -1
- package/dist/assets/insertionBoundaries-Cst5Z6Cd.js +1 -0
- package/dist/assets/{ja-BqqTaWTi.js → ja-1mgxunoM.js} +4 -4
- package/dist/assets/{ko-DP2XPM37.js → ko-Ra9znPcL.js} +4 -4
- package/dist/assets/{main-KwlAomA8.js → main-CEs8ipuu.js} +2 -2
- package/dist/assets/main-Dkzuy7EG.js +169 -0
- package/dist/assets/main-qSeeJRW1.css +1 -0
- package/dist/assets/{markdownHeightCache-D9-B1wdY.js → markdownHeightCache-DhLhFBWX.js} +1 -1
- package/dist/assets/{mcpQueries-3GTWeMU-.js → mcpQueries-BBpCJgvK.js} +1 -1
- package/dist/assets/miniChat-CR7k42bZ.js +2 -0
- package/dist/assets/mobile-rvypjUpd.js +2 -0
- package/dist/assets/multirun-DqmJk0mh.js +3 -0
- package/dist/assets/persistence-CwHY3fDg.js +3 -0
- package/dist/assets/pl-B714sdql.js +15 -0
- package/dist/assets/platform-Bxaalnc8.js +6 -0
- package/dist/assets/projectMeta-BhKfv5cj.js +1 -0
- package/dist/assets/{pt-BR-W09Q2pvn.js → pt-BR-DVoT_I7_.js} +4 -4
- package/dist/assets/{relay-image-stream-DrdVj99c.js → relay-image-stream-D2XooXKZ.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-BLay6f9j.js +2 -0
- package/dist/assets/renderMobileApp-BrikirqO.js +28 -0
- package/dist/assets/{runtime-fetch-Fs9ZV2V8.js → runtime-fetch-CiqQd__Y.js} +1 -1
- package/dist/assets/runtime-switch-BJM2k_uH.js +1 -0
- package/dist/assets/runtimeConfig-DjKsteH0.js +2 -0
- package/dist/assets/runtimeEndpointReset-D_vfNOQz.css +1 -0
- package/dist/assets/runtimeEndpointReset-DbfaE3dL.js +148 -0
- package/dist/assets/sessionChangeSummary-Cgq9RPUq.js +2 -0
- package/dist/assets/{sessionLookup-0Z4e-J1g.js → sessionLookup-BiE36BTy.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-CE7W7lJ9.js → sortable-tabs-strip-CKKp4p3Y.js} +1 -1
- package/dist/assets/src-BZ0tISrf.css +2 -0
- package/dist/assets/{store-jaP79mqI.js → store-l_X_jQNz.js} +4 -4
- package/dist/assets/{terminalApi-mDR2jXTo.js → terminalApi-DSJOXNRQ.js} +1 -1
- package/dist/assets/{textarea-XD2oX_Ee.js → textarea-DUjc3iDJ.js} +1 -1
- package/dist/assets/{tooltip-DYeQ_Hvy.js → tooltip-CBHR50fA.js} +1 -1
- package/dist/assets/{uk-BgFzz8m2.js → uk-ChNnTgKP.js} +4 -4
- package/dist/assets/{url-CGbJbiJq.js → url-CEnO45_n.js} +1 -1
- package/dist/assets/{useAppFontEffects-y_GTVXhJ.js → useAppFontEffects-CWzjhjXr.js} +55 -55
- package/dist/assets/{useAssistantUIStore-Bz4tRflF.js → useAssistantUIStore-CXj8c_tt.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-Dtqco-Um.js +1 -0
- package/dist/assets/{useEffectiveDirectory-BW9jNyuK.js → useEffectiveDirectory-DNbDaaNV.js} +1 -1
- package/dist/assets/{useFontPreferences-Bjx8zkGM.js → useFontPreferences-DGbTdGy3.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DYKC2swj.js +415 -0
- package/dist/assets/useSessionAutoCleanup-BI0xQcio.js +2 -0
- package/dist/assets/{useUIStore-syVmbuDA.js → useUIStore-DNkuudYO.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-BUcSLKlL.js → useWorkerHighlightedLines-BoyYALWC.js} +1 -1
- package/dist/assets/vendor-opencode-client-MF2GBrFx.js +12 -0
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-DzRqcymP.js} +1 -1
- package/dist/assets/{zh-CN-BhqSLgUY.js → zh-CN-DHEFf4u1.js} +4 -4
- package/dist/assets/{zh-TW-BsaBgY-p.js → zh-TW-rdSa8atW.js} +4 -4
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +3 -3
- package/server/index.js +8 -20
- package/server/lib/client-auth/remote-clients.js +15 -0
- package/server/lib/client-auth/remote-clients.test.js +42 -1
- package/server/lib/git/DOCUMENTATION.md +1 -0
- package/server/lib/git/routes.js +7 -4
- package/server/lib/git/routes.test.js +34 -0
- package/server/lib/git/service.js +3 -1
- package/server/lib/llm/DOCUMENTATION.md +6 -4
- package/server/lib/llm/generate.js +8 -9
- package/server/lib/llm/generate.test.js +33 -6
- package/server/lib/message-queue/service.js +1 -1
- package/server/lib/opencode/DOCUMENTATION.md +33 -22
- package/server/lib/opencode/agents.js +13 -22
- package/server/lib/opencode/auth-state-runtime.js +3 -3
- package/server/lib/opencode/auth-state-runtime.test.js +59 -0
- package/server/lib/opencode/auth.d.ts +13 -0
- package/server/lib/opencode/auth.js +74 -64
- package/server/lib/opencode/auth.test.js +57 -0
- package/server/lib/opencode/commands.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +72 -0
- package/server/lib/opencode/config-entity-routes.js +10 -123
- package/server/lib/opencode/config-entity-routes.test.js +23 -11
- package/server/lib/opencode/config-files.d.ts +17 -0
- package/server/lib/opencode/config-files.js +187 -0
- package/server/lib/opencode/config-files.test.js +201 -0
- package/server/lib/opencode/core-routes.js +17 -1
- package/server/lib/opencode/core-routes.test.js +43 -15
- package/server/lib/opencode/credential-boundary.d.ts +1 -0
- package/server/lib/opencode/credential-boundary.js +11 -0
- package/server/lib/opencode/ensure-cli.js +4 -1
- package/server/lib/opencode/env-config.js +4 -1
- package/server/lib/opencode/env-config.test.js +28 -0
- package/server/lib/opencode/env-runtime.test.js +18 -1
- package/server/lib/opencode/feature-routes-runtime.js +9 -7
- package/server/lib/opencode/hmr-state-runtime.js +4 -4
- package/server/lib/opencode/lifecycle.js +1 -49
- package/server/lib/opencode/lifecycle.test.js +30 -4
- package/server/lib/opencode/location-release.d.ts +8 -0
- package/server/lib/opencode/location-release.js +32 -0
- package/server/lib/opencode/location-release.test.js +43 -0
- package/server/lib/opencode/mcp.js +1 -1
- package/server/lib/opencode/opencode2-pin.js +2 -1
- package/server/lib/opencode/owned-runtime-upgrade.test.js +5 -5
- package/server/lib/opencode/plugin-routes.test.js +4 -2
- package/server/lib/opencode/plugins.js +12 -60
- package/server/lib/opencode/plugins.test.js +5 -3
- package/server/lib/opencode/providers.js +6 -3
- package/server/lib/opencode/proxy.js +7 -0
- package/server/lib/opencode/proxy.test.js +13 -0
- package/server/lib/opencode/remote-upgrade.integration.test.js +66 -0
- package/server/lib/opencode/routes.js +10 -8
- package/server/lib/opencode/routes.test.js +51 -10
- package/server/lib/opencode/runtime-contract.js +3 -2
- package/server/lib/opencode/runtime-contract.test.js +12 -12
- package/server/lib/opencode/settings-normalization-runtime.js +3 -1
- package/server/lib/opencode/settings-normalization-runtime.test.js +36 -0
- package/server/lib/opencode/settings-runtime.test.js +58 -1
- package/server/lib/opencode/shared.js +6 -175
- package/server/lib/opencode/update-discovery.js +39 -0
- package/server/lib/opencode/update-discovery.test.js +39 -0
- package/server/lib/opencode/upgrade-screen.js +4 -11
- package/server/lib/opencode/upgrade-screen.test.js +25 -25
- package/server/lib/opencode/version-alignment.test.js +40 -0
- package/server/lib/question-auto-delegate/DOCUMENTATION.md +21 -6
- package/server/lib/question-auto-delegate/core.d.ts +5 -1
- package/server/lib/question-auto-delegate/core.js +21 -13
- package/server/lib/question-auto-delegate/form-io.d.ts +6 -0
- package/server/lib/question-auto-delegate/form-io.js +39 -0
- package/server/lib/question-auto-delegate/runtime.js +4 -24
- package/server/lib/question-auto-delegate/runtime.test.js +138 -0
- package/server/lib/quota/DOCUMENTATION.md +9 -0
- package/server/lib/quota/provider-credentials.test.js +29 -0
- package/server/lib/quota/providers/claude.js +4 -4
- package/server/lib/quota/providers/codex.js +4 -4
- package/server/lib/quota/providers/codex.test.js +1 -1
- package/server/lib/quota/providers/copilot.js +5 -5
- package/server/lib/quota/providers/google/auth.js +3 -3
- package/server/lib/quota/providers/google/index.js +2 -2
- package/server/lib/quota/providers/index.js +3 -7
- package/server/lib/quota/providers/index.test.js +2 -2
- package/server/lib/quota/providers/kimi.js +4 -4
- package/server/lib/quota/providers/minimax-shared.js +4 -4
- package/server/lib/quota/providers/nanogpt.js +4 -4
- package/server/lib/quota/providers/openai.js +4 -4
- package/server/lib/quota/providers/openrouter.js +4 -4
- package/server/lib/quota/providers/wafer.js +4 -4
- package/server/lib/quota/providers/zai.js +4 -4
- package/server/lib/quota/providers/zhipuai-coding-plan.js +6 -20
- package/server/lib/quota/routes.js +1 -1
- package/server/lib/relay/DOCUMENTATION.md +3 -1
- package/server/lib/relay/service.js +1 -0
- package/server/lib/relay/service.test.js +13 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +8 -1
- package/server/lib/scheduled-tasks/runtime.js +12 -4
- package/server/lib/scheduled-tasks/runtime.test.js +81 -6
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/session-goal/runtime.js +4 -4
- package/server/lib/session-goal/runtime.test.js +32 -3
- package/server/lib/session-index/DOCUMENTATION.md +7 -0
- package/server/lib/session-index/event-ingest.js +13 -0
- package/server/lib/session-index/event-ingest.test.js +7 -0
- package/server/lib/session-index/service.js +45 -0
- package/server/lib/session-index/service.test.js +47 -0
- package/server/lib/session-metadata/DOCUMENTATION.md +6 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +23 -0
- package/server/lib/session-metadata/session-projection.d.ts +1 -0
- package/server/lib/session-metadata/session-projection.js +23 -0
- package/server/lib/session-metadata/session-projection.test.js +35 -0
- package/server/lib/session-title/DOCUMENTATION.md +52 -14
- package/server/lib/session-title/native-runtime.test.js +59 -5
- package/server/lib/session-title/runtime.js +63 -17
- package/server/lib/session-title/session-access.js +108 -3
- package/server/lib/session-title/session-access.test.js +125 -7
- package/server/lib/tts/DOCUMENTATION.md +7 -5
- package/server/lib/tts/credential-source.test.js +23 -0
- package/server/lib/tts/routes.js +2 -2
- package/server/lib/tts/service.js +16 -37
- package/dist/assets/AssistantView-CLFPj5Ki.js +0 -3
- package/dist/assets/Icon-B8fSTLdP.js +0 -1
- package/dist/assets/MarkdownRenderer-BK69MEHQ.js +0 -2
- package/dist/assets/MobileDetailNavigation-agQ-Yhpd.js +0 -1
- package/dist/assets/MobileSurface-D0HP_lTS.js +0 -2
- package/dist/assets/SettingsView-xLYb22wV.js +0 -25
- package/dist/assets/appThemeRegistry-LkVH62uF.js +0 -1
- package/dist/assets/desktopRelayRestore-CHEwrI93.js +0 -1
- package/dist/assets/dialog-CQ63sc9E.js +0 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +0 -1
- package/dist/assets/fileMentionTouchSelection-CBzDuQxP.js +0 -3
- package/dist/assets/insertionBoundaries-DQrE5qG2.js +0 -1
- package/dist/assets/main-D2BzpnUt.css +0 -1
- package/dist/assets/main-DYISNvWY.js +0 -169
- package/dist/assets/miniChat-DYIqdWi7.js +0 -2
- package/dist/assets/mobile-CwiCcmWt.js +0 -2
- package/dist/assets/multirun-C3snjTFs.js +0 -4
- package/dist/assets/persistence-D0y7226-.js +0 -3
- package/dist/assets/pl-BCf5uLlK.js +0 -15
- package/dist/assets/platform-CurqO28D.js +0 -6
- package/dist/assets/projectMeta-BkhYFMaM.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-7iyh_419.js +0 -2
- package/dist/assets/renderMobileApp-y0_NeckR.js +0 -28
- package/dist/assets/runtime-switch-LvWDU-SL.js +0 -1
- package/dist/assets/runtimeConfig-12C5j02f.js +0 -2
- package/dist/assets/runtimeEndpointReset-DTY2ZXXb.js +0 -149
- package/dist/assets/sessionChangeSummary-CwPBIb3Z.js +0 -2
- package/dist/assets/src-lS1keDVV.css +0 -2
- package/dist/assets/useDesktopWindowControlsLayout-CR_DcY77.js +0 -1
- package/dist/assets/useMobileNavigationStore-DwupvLSA.js +0 -414
- package/dist/assets/useSessionAutoCleanup-DkGO_usj.js +0 -2
- package/dist/assets/vendor-opencode-client-C12gfjag.js +0 -12
- package/server/lib/opencode/snippets.js +0 -242
- package/server/lib/opencode/snippets.test.js +0 -97
|
@@ -4,8 +4,8 @@ Server-side watcher that regenerates a session's sidebar title from the
|
|
|
4
4
|
conversation's **main subject** (overall feature / goal) with the small model
|
|
5
5
|
(`lib/small-model`), then PATCHes `title` plus `metadata.openchamber.titleRefresh`.
|
|
6
6
|
|
|
7
|
-
OpenCode
|
|
8
|
-
|
|
7
|
+
OpenCode starts its own initial title through `SessionTitle.generate`.
|
|
8
|
+
This module auto-refreshes sparsely (first user
|
|
9
9
|
admission of a new session, first newly-sent reply on a fork) and on explicit smart-title
|
|
10
10
|
requests, naming the durable work being done — not the last wrap-up utterance
|
|
11
11
|
like "commit and push". Background auto refreshes still respect a 5-minute
|
|
@@ -50,18 +50,21 @@ extension-only VS Code retains the limitation below.
|
|
|
50
50
|
assistant reply run concurrently. Generation publishes `isGenerating` via
|
|
51
51
|
the existing Host metadata path, then the final title through `session.updated`.
|
|
52
52
|
Later user admissions/idle events do not regenerate the initial title.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
If `session.created` was missed, the admission's session read recovers
|
|
54
|
+
an empty/default title with no previous auto-title metadata and starts
|
|
55
|
+
immediately. The legacy first-idle edge remains a fallback when no
|
|
56
|
+
admission was observed.
|
|
57
|
+
A fork title (`(fork #n)`) refreshes on its first newly-admitted user
|
|
58
|
+
message, bypassing inherited title metadata and throttle without waiting
|
|
59
|
+
for the assistant. Legacy message-only events retain the idle fallback.
|
|
57
60
|
If the fork's `session.created` was lost (SSE reconnect gap, server or
|
|
58
61
|
OpenCode restart), the first newly-created user message lazily re-registers
|
|
59
62
|
the pending fork from the session read inside `recordUserActivity` (fork
|
|
60
63
|
title + message created after the fork + activity timestamp not yet past
|
|
61
|
-
|
|
64
|
+
the fork time), so the first-send refresh still fires.
|
|
62
65
|
Ordinary later idle transitions do **not** arm another refresh. Any
|
|
63
66
|
`busy`/`retry` status or a fresh user `message.updated` still clears an
|
|
64
|
-
|
|
67
|
+
ordinary already-armed timer; admitted initial/fork-title timers are exempt.
|
|
65
68
|
A sidebar smart-title request sets `titleRefresh.requestedAt`; its
|
|
66
69
|
`session.updated` event arms the same flow immediately (forced / manual
|
|
67
70
|
refresh is unaffected by the background gate).
|
|
@@ -124,6 +127,8 @@ and other session updates keep the existing ordering timestamp unchanged.
|
|
|
124
127
|
`sessionTitleRefreshEnabled` in OpenChamber settings (Settings → Chat,
|
|
125
128
|
default on) controls background title refreshes. Explicit smart-title actions
|
|
126
129
|
still run when background refresh is disabled.
|
|
130
|
+
The gate controls model generation, not synchronization of an already committed
|
|
131
|
+
title authority; reconciliation continues without additional model calls.
|
|
127
132
|
|
|
128
133
|
Settings → Summary AI controls the title model and the optional
|
|
129
134
|
`summarySessionTitlePrompt` override. The configured custom API receives the
|
|
@@ -131,18 +136,51 @@ title transcript only after the user explicitly selects that source.
|
|
|
131
136
|
|
|
132
137
|
## Manual rename contract
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
`
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
OpenChamber owns the title once it generates one or the user renames it here.
|
|
140
|
+
`metadata.openchamber.titleAuthority` stores `{ sessionID, title, revision,
|
|
141
|
+
source }` on the OpenCode session via the metadata store. `sessionID` prevents
|
|
142
|
+
forks inheriting a parent's title lock. Existing `lastAutoTitle` records are not
|
|
143
|
+
promoted: they cannot distinguish an external manual rename from native generation.
|
|
144
|
+
|
|
145
|
+
`session-access.js` commits the authority before writing the official
|
|
146
|
+
`session.update({ sessionID, title })`. A conditional metadata mutation checks
|
|
147
|
+
the revision captured before model generation, so a late model result cannot
|
|
148
|
+
undo a newer manual rename. Even output equal to the current title claims
|
|
149
|
+
ownership. Sidebar rename commits a new manual authority before its official
|
|
150
|
+
title update. Smart-title requests explicitly allow a new summary generation.
|
|
151
|
+
Ordinary follow-up messages still do not regenerate titles.
|
|
152
|
+
|
|
153
|
+
Native `session.renamed`, full session updates and Host metadata writes trigger
|
|
154
|
+
coalesced per-session reconciliation. It reads current authority and upstream
|
|
155
|
+
title, writes only on drift, then verifies again. Duplicate/out-of-order events
|
|
156
|
+
are hints, never authority. Retries use exponential backoff capped at 60 seconds;
|
|
157
|
+
404 and shutdown stop pending work. Connection/startup recovery checks persisted
|
|
158
|
+
owned sessions serially, without model calls. Failed upstream writes retain the
|
|
159
|
+
durable intent and report failure rather than claiming successful synchronization.
|
|
160
|
+
|
|
161
|
+
List/detail and live/replayed rename projections retain the owned title while
|
|
162
|
+
repair runs; the real OpenCode `session.title` is repaired as well. The upstream
|
|
163
|
+
API has no atomic title lock, so other OpenCode clients can briefly observe a
|
|
164
|
+
conflicting write until repair completes. A running, connected Host is required
|
|
165
|
+
for repair; restart/reconnect recovers missed writes. Direct OpenCode renames
|
|
166
|
+
(manual or generated) cannot replace an owned title: rename in OpenChamber to
|
|
167
|
+
change it. Extension-only VS Code receives shared metadata/projection behavior,
|
|
168
|
+
but repair requires a connected OpenChamber Host.
|
|
138
169
|
|
|
139
170
|
## Limitations
|
|
140
171
|
|
|
141
172
|
Title language follows the user's real message text. Assistant responses, tool
|
|
142
173
|
output, and transcript labels provide context without selecting the title language.
|
|
143
174
|
|
|
175
|
+
Attachments do not gate title generation. Admission retains user text plus at
|
|
176
|
+
most eight bounded filename/MIME descriptors, never attachment bytes or URLs.
|
|
177
|
+
Attachment-only messages also start generation; the text-only summary model can
|
|
178
|
+
name the attachment subject from those descriptors, but is explicitly told that
|
|
179
|
+
file contents are not included. Transcript-based/manual generation uses the
|
|
180
|
+
same file descriptors. This does not perform image recognition or file parsing.
|
|
181
|
+
|
|
144
182
|
- Lives in the web server, so VS Code (extension-only) does not generate
|
|
145
183
|
refreshes; it still receives `session.updated` title changes produced by a
|
|
146
184
|
web/desktop instance of the same OpenCode server.
|
|
147
|
-
- First OpenCode-generated
|
|
148
|
-
|
|
185
|
+
- First OpenCode-generated titles may be replaced by OpenChamber's initial title,
|
|
186
|
+
regardless of which model completes first.
|
|
@@ -55,6 +55,29 @@ it.each([false, true])('runs a Host smart-title request and clears loading (mode
|
|
|
55
55
|
}
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
it('does not start a pending initial title over a manual authority committed before generation', async () => {
|
|
59
|
+
vi.useFakeTimers();
|
|
60
|
+
const generate = vi.fn();
|
|
61
|
+
const session = { id: 'ses_manual', title: 'Manual', metadata: { openchamber: {
|
|
62
|
+
titleAuthority: { sessionID: 'ses_manual', title: 'Manual', revision: 'manual', source: 'manual' },
|
|
63
|
+
} } };
|
|
64
|
+
const runtime = createSessionTitleRuntime({
|
|
65
|
+
sessionAccess: {
|
|
66
|
+
get: async () => session,
|
|
67
|
+
messages: async () => [{ info: { id: 'u1', role: 'user' }, parts: [{ type: 'text', text: 'Initial work' }] }],
|
|
68
|
+
update: async () => {},
|
|
69
|
+
},
|
|
70
|
+
getSmallModelService: async () => ({ generateSmallModelText: generate }),
|
|
71
|
+
isTitleRefreshEnabled: () => true,
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
runtime.processPayload({ type: 'session.created', data: { sessionID: session.id } });
|
|
75
|
+
runtime.processPayload({ type: 'session.execution.succeeded', data: { sessionID: session.id } });
|
|
76
|
+
await vi.runAllTimersAsync();
|
|
77
|
+
expect(generate).not.toHaveBeenCalled();
|
|
78
|
+
} finally { runtime.stop(); }
|
|
79
|
+
});
|
|
80
|
+
|
|
58
81
|
it('uses the real v2 client and publishes the generated title without another session click', async () => {
|
|
59
82
|
vi.useFakeTimers();
|
|
60
83
|
configureServerOpenCodeFetchGate(null, { allowMissingContract: true });
|
|
@@ -85,6 +108,12 @@ it('uses the real v2 client and publishes the generated title without another se
|
|
|
85
108
|
buildOpenCodeUrl: (path) => `http://opencode${path}`,
|
|
86
109
|
getOpenCodeAuthHeaders: () => ({}),
|
|
87
110
|
readSessionMetadata: async () => metadata,
|
|
111
|
+
mutateSessionMetadata: async (_id, decide) => {
|
|
112
|
+
const decision = decide(metadata);
|
|
113
|
+
if (!decision.ok) return { committed: false };
|
|
114
|
+
metadata = mergeMetadataPatch(metadata, decision.patch);
|
|
115
|
+
return { committed: true, metadata };
|
|
116
|
+
},
|
|
88
117
|
persistSessionMetadata: async (_id, patch) => {
|
|
89
118
|
metadata = mergeMetadataPatch(metadata, patch);
|
|
90
119
|
events.push({ type: 'metadata', metadata: structuredClone(metadata) });
|
|
@@ -105,15 +134,30 @@ it('uses the real v2 client and publishes the generated title without another se
|
|
|
105
134
|
expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh', directory: '/repo' } });
|
|
106
135
|
expect(metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
|
|
107
136
|
expect(requests).toContain('PATCH /api/session/ses_title');
|
|
137
|
+
session.title = 'Late native title';
|
|
138
|
+
runtime.processPayload({ type: 'session.renamed', created: 2000, data: {
|
|
139
|
+
sessionID: session.id, title: session.title,
|
|
140
|
+
} });
|
|
141
|
+
await vi.runAllTimersAsync();
|
|
142
|
+
expect(session.title).toBe('Title refresh');
|
|
143
|
+
expect(events.at(-1)).toMatchObject({ type: 'session', row: { title: 'Title refresh' } });
|
|
144
|
+
const writes = requests.filter((request) => request.startsWith('PATCH')).length;
|
|
145
|
+
runtime.processPayload({ type: 'session.renamed', created: 500, data: {
|
|
146
|
+
sessionID: session.id, title: 'Replayed stale title',
|
|
147
|
+
} });
|
|
148
|
+
await vi.runAllTimersAsync();
|
|
149
|
+
expect(requests.filter((request) => request.startsWith('PATCH'))).toHaveLength(writes);
|
|
108
150
|
} finally {
|
|
109
151
|
runtime.stop();
|
|
110
152
|
}
|
|
111
153
|
});
|
|
112
154
|
|
|
113
|
-
it('
|
|
155
|
+
it.each(['new', 'missed-create', 'fork', 'missed-fork-create'].flatMap((scenario) =>
|
|
156
|
+
['text', 'text-and-file', 'file-only'].map((content) => ({ scenario, content })),
|
|
157
|
+
))('starts the first title before assistant completion ($scenario, $content)', async ({ scenario, content }) => {
|
|
114
158
|
vi.useFakeTimers();
|
|
115
159
|
const session = {
|
|
116
|
-
id: 'ses_first', title: '', time: { created: 1, updated: 1 },
|
|
160
|
+
id: 'ses_first', title: scenario.includes('fork') ? 'Original (fork #1)' : '', time: { created: 1, updated: 1 },
|
|
117
161
|
metadata: {},
|
|
118
162
|
};
|
|
119
163
|
let finishTitle;
|
|
@@ -135,26 +179,36 @@ it('starts the first title from admitted user text while the assistant is still
|
|
|
135
179
|
});
|
|
136
180
|
const emit = (type, data) => runtime.processPayload({ type, data, created: 1000, location: { directory: '/repo' } });
|
|
137
181
|
try {
|
|
138
|
-
|
|
182
|
+
if (!scenario.startsWith('missed')) {
|
|
183
|
+
emit('session.created', { sessionID: session.id, title: session.title, time: session.time, location: { directory: '/repo' } });
|
|
184
|
+
}
|
|
139
185
|
emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_synthetic', item: {
|
|
140
186
|
type: 'synthetic', delivery: 'steer', payload: { text: 'Internal instructions' },
|
|
141
187
|
} });
|
|
142
188
|
await vi.runAllTimersAsync();
|
|
143
189
|
expect(generate).not.toHaveBeenCalled();
|
|
144
190
|
emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_first', item: {
|
|
145
|
-
type: 'user', delivery: 'steer', payload: {
|
|
191
|
+
type: 'user', delivery: 'steer', payload: {
|
|
192
|
+
text: content === 'file-only' ? '' : 'Implement a session title generator',
|
|
193
|
+
...(content !== 'text' ? { files: [{ name: 'title-bug.png', mime: 'image/png', source: { type: 'inline' }, data: 'YQ=='.repeat(100_000) }] } : {}),
|
|
194
|
+
},
|
|
146
195
|
} });
|
|
147
196
|
emit('session.execution.started', { sessionID: session.id });
|
|
148
197
|
emit('session.status', { sessionID: session.id, status: { type: 'busy' } });
|
|
149
198
|
await vi.runAllTimersAsync();
|
|
150
199
|
expect(generate).toHaveBeenCalledTimes(1);
|
|
151
|
-
|
|
200
|
+
const prompt = generate.mock.calls[0][0].prompt;
|
|
201
|
+
if (content !== 'file-only') expect(prompt).toContain('Implement a session title generator');
|
|
202
|
+
if (content !== 'text') expect(prompt).toContain('title-bug.png');
|
|
203
|
+
expect(prompt).not.toContain('YQ==');
|
|
204
|
+
expect(prompt.length).toBeLessThan(4000);
|
|
152
205
|
expect(session.metadata.openchamber?.titleRefresh?.isGenerating).toBe(true);
|
|
153
206
|
expect(access.messages).not.toHaveBeenCalled();
|
|
154
207
|
finishTitle({ text: 'Session titles', providerID: 'test', modelID: 'test' });
|
|
155
208
|
await vi.runAllTimersAsync();
|
|
156
209
|
expect(session.title).toBe('Session titles');
|
|
157
210
|
expect(session.metadata.openchamber.titleRefresh.isGenerating).toBeUndefined();
|
|
211
|
+
expect(access.messages).not.toHaveBeenCalled();
|
|
158
212
|
emit('session.execution.succeeded', { sessionID: session.id });
|
|
159
213
|
emit('session.inbox.enqueued', { sessionID: session.id, inboxID: 'msg_second', item: {
|
|
160
214
|
type: 'user', delivery: 'steer', payload: { text: 'Follow up on the implementation' },
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Session title refresh: regenerate the sidebar title from the conversation's
|
|
2
2
|
// MAIN SUBJECT (overall feature / goal), not just the last wrap-up utterance.
|
|
3
3
|
// OpenCode only titles once from the first user message. Auto refresh is sparse
|
|
4
|
-
// for title stability: first
|
|
4
|
+
// for title stability: first user admission of a new session, first send on a
|
|
5
5
|
// fork, plus explicit smart-title / forced refresh. Continuing the same work
|
|
6
6
|
// should keep naming the thing being done — not "commit and push". This
|
|
7
7
|
// watcher throttles to at most one refresh per session every TITLE_THROTTLE_MS
|
|
@@ -46,8 +46,8 @@ const FORK_TITLE_RE = / \(fork #\d+\)$/;
|
|
|
46
46
|
const MULTI_RUN_GROUP_SLUG = /^[a-z0-9](?:[a-z0-9-]{0,48}[a-z0-9])?$/;
|
|
47
47
|
const MULTI_RUN_GROUP = /^g[1-9]\d*$/;
|
|
48
48
|
|
|
49
|
-
//
|
|
50
|
-
//
|
|
49
|
+
// Generation switch (default on). Existing owned titles still reconcile when
|
|
50
|
+
// generation is off; explicit smart-title requests also remain available.
|
|
51
51
|
const isSessionTitleRefreshEnabled = () => {
|
|
52
52
|
try {
|
|
53
53
|
const raw = fs.readFileSync(OPENCHAMBER_SETTINGS_FILE, 'utf8');
|
|
@@ -201,13 +201,22 @@ const extractUserMessage = (payload) => {
|
|
|
201
201
|
const item = properties.item;
|
|
202
202
|
if (typeof properties.sessionID !== 'string' || item?.type !== 'user') return null;
|
|
203
203
|
const text = typeof item.payload?.text === 'string' ? item.payload.text.slice(0, TRANSCRIPT_USER_CHAR_LIMIT) : '';
|
|
204
|
+
const files = Array.isArray(item.payload?.files) ? item.payload.files.slice(0, 8) : [];
|
|
205
|
+
const parts = [
|
|
206
|
+
...(text.trim() ? [{ type: 'text', text }] : []),
|
|
207
|
+
...files.filter((file) => file && typeof file === 'object').map((file) => ({
|
|
208
|
+
type: 'file',
|
|
209
|
+
filename: typeof file.name === 'string' ? file.name.slice(0, 200) : '',
|
|
210
|
+
mime: typeof file.mime === 'string' ? file.mime.slice(0, 100) : '',
|
|
211
|
+
})),
|
|
212
|
+
];
|
|
204
213
|
return {
|
|
205
214
|
sessionId: properties.sessionID,
|
|
206
215
|
createdAt: payload.created ?? 0,
|
|
207
216
|
directory: payload.location?.directory ?? '',
|
|
208
|
-
initialRecord:
|
|
217
|
+
initialRecord: parts.length ? {
|
|
209
218
|
info: { id: properties.inboxID, role: 'user' },
|
|
210
|
-
parts
|
|
219
|
+
parts,
|
|
211
220
|
} : null,
|
|
212
221
|
};
|
|
213
222
|
}
|
|
@@ -230,7 +239,13 @@ const messagePartsToText = (message) => {
|
|
|
230
239
|
? TRANSCRIPT_ASSISTANT_CHAR_LIMIT
|
|
231
240
|
: TRANSCRIPT_USER_CHAR_LIMIT;
|
|
232
241
|
return parts
|
|
233
|
-
.map((part) =>
|
|
242
|
+
.map((part) => {
|
|
243
|
+
if (part?.type === 'text' && typeof part.text === 'string') return part.text;
|
|
244
|
+
if (part?.type !== 'file') return '';
|
|
245
|
+
const filename = typeof part.filename === 'string' ? part.filename.slice(0, 200) : '';
|
|
246
|
+
const mime = typeof part.mime === 'string' ? part.mime.slice(0, 100) : '';
|
|
247
|
+
return `[Attachment: ${filename || mime || 'file'}${filename && mime ? ` (${mime})` : ''}; contents not included]`;
|
|
248
|
+
})
|
|
234
249
|
.filter(Boolean)
|
|
235
250
|
.join('\n')
|
|
236
251
|
.slice(0, charLimit);
|
|
@@ -495,7 +510,7 @@ export const createSessionTitleRuntime = ({
|
|
|
495
510
|
});
|
|
496
511
|
};
|
|
497
512
|
|
|
498
|
-
const recordUserActivity = async (sessionId, directory, createdAt) => {
|
|
513
|
+
const recordUserActivity = async (sessionId, directory, createdAt, initialRecord) => {
|
|
499
514
|
if (!Number.isFinite(createdAt) || createdAt <= 0) return;
|
|
500
515
|
const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
|
|
501
516
|
.catch(() => null);
|
|
@@ -508,7 +523,7 @@ export const createSessionTitleRuntime = ({
|
|
|
508
523
|
// never learned about the fork. The first newly-created user message on a
|
|
509
524
|
// fork-titled session (message created after the fork, before any activity
|
|
510
525
|
// timestamp advanced past the fork) rebuilds that pending entry so the
|
|
511
|
-
//
|
|
526
|
+
// first admission can trigger the refresh. Idempotent: the normal
|
|
512
527
|
// session.created path already holds a pending entry, and once activity
|
|
513
528
|
// advances past the fork time later messages never re-register.
|
|
514
529
|
const forkCreated = Number(session?.time?.created);
|
|
@@ -526,6 +541,26 @@ export const createSessionTitleRuntime = ({
|
|
|
526
541
|
});
|
|
527
542
|
}
|
|
528
543
|
|
|
544
|
+
// Recover first-send admission even when session.created was missed.
|
|
545
|
+
// Use the admitted text: the message projection may not exist yet.
|
|
546
|
+
if (initialRecord && !stopped && !inflight.has(sessionId) && !initialRefreshes.has(sessionId)
|
|
547
|
+
&& !forkFirstRefreshes.has(sessionId) && !session.parentID && !isSystemOwnedSession(session)) {
|
|
548
|
+
const pendingFork = forkFirstSendPending.get(sessionId);
|
|
549
|
+
const firstForkSend = pendingFork && createdAt > pendingFork.createdAt;
|
|
550
|
+
const untitled = !session.title || isDefaultSessionTitle(session.title);
|
|
551
|
+
if (firstForkSend || (untitled && !meta.generatedAt && !meta.lastAutoTitle)) {
|
|
552
|
+
newSessions.delete(sessionId);
|
|
553
|
+
if (firstForkSend) {
|
|
554
|
+
forkFirstSendPending.delete(sessionId);
|
|
555
|
+
forkFirstRefreshes.add(sessionId);
|
|
556
|
+
} else {
|
|
557
|
+
initialRefreshes.add(sessionId);
|
|
558
|
+
}
|
|
559
|
+
initialUserMessages.set(sessionId, initialRecord);
|
|
560
|
+
armTimer(sessionId, directory, 0);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
529
564
|
const previous = Number(meta.titleRefresh.activityUpdatedAt);
|
|
530
565
|
const activityUpdatedAt = Number.isFinite(previous) ? Math.max(previous, createdAt) : createdAt;
|
|
531
566
|
if (activityUpdatedAt === previous) return;
|
|
@@ -568,6 +603,8 @@ export const createSessionTitleRuntime = ({
|
|
|
568
603
|
|
|
569
604
|
const currentTitle = typeof session.title === 'string' ? session.title : '';
|
|
570
605
|
if (looksLikeMultiRunSessionTitle(currentTitle)) return;
|
|
606
|
+
const authority = session.metadata?.openchamber?.titleAuthority;
|
|
607
|
+
if (!forceRefresh && authority?.sessionID === sessionId && authority.source === 'manual') return;
|
|
571
608
|
|
|
572
609
|
const meta = readTitleRefreshMeta(session);
|
|
573
610
|
const canRefreshForkTitle = forkFirstRefresh && isForkedSessionTitle(currentTitle);
|
|
@@ -665,12 +702,7 @@ export const createSessionTitleRuntime = ({
|
|
|
665
702
|
}
|
|
666
703
|
|
|
667
704
|
const nextTitle = cleanGeneratedTitle(generated?.text);
|
|
668
|
-
if (!nextTitle) return;
|
|
669
|
-
if (nextTitle === currentTitle) {
|
|
670
|
-
// Still stamp metadata so we do not keep re-calling for the same tail.
|
|
671
|
-
lastGeneratedAtBySession.set(sessionId, now());
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
705
|
+
if (!nextTitle || stopped) return;
|
|
674
706
|
|
|
675
707
|
// Re-check before write: user may have renamed, or a new message arrived.
|
|
676
708
|
const freshSession = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory })
|
|
@@ -683,7 +715,7 @@ export const createSessionTitleRuntime = ({
|
|
|
683
715
|
return;
|
|
684
716
|
}
|
|
685
717
|
|
|
686
|
-
const latestMessages = await fetchRecentMessages(sessionId, directory);
|
|
718
|
+
const latestMessages = initialUserMessage ? null : await fetchRecentMessages(sessionId, directory);
|
|
687
719
|
const latestAssistantId = (() => {
|
|
688
720
|
if (!latestMessages) return null;
|
|
689
721
|
for (let i = latestMessages.length - 1; i >= 0; i -= 1) {
|
|
@@ -707,6 +739,8 @@ export const createSessionTitleRuntime = ({
|
|
|
707
739
|
method: 'PATCH',
|
|
708
740
|
body: {
|
|
709
741
|
title: nextTitle,
|
|
742
|
+
expectedTitleRevision: session.metadata?.openchamber?.titleAuthority?.sessionID === sessionId
|
|
743
|
+
? session.metadata.openchamber.titleAuthority.revision : null,
|
|
710
744
|
metadata: {
|
|
711
745
|
openchamber: {
|
|
712
746
|
titleRefresh: {
|
|
@@ -742,6 +776,16 @@ export const createSessionTitleRuntime = ({
|
|
|
742
776
|
const processPayload = (payload, directoryHint = '') => {
|
|
743
777
|
if (stopped) return;
|
|
744
778
|
directoryHint = directoryHint || payload?.location?.directory || '';
|
|
779
|
+
if (payload?.type === 'server.connected') {
|
|
780
|
+
void sessionAccess?.reconcileAll?.().catch((error) => {
|
|
781
|
+
console.warn('[session-title] recovery failed:', error?.message || error);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (payload?.type === 'session.renamed' || payload?.type === 'session.updated'
|
|
785
|
+
|| payload?.type === 'openchamber:session-metadata') {
|
|
786
|
+
const data = payload.data ?? payload.properties ?? {};
|
|
787
|
+
sessionAccess?.reconcile?.(data.sessionID ?? data.info?.id ?? data.id, directoryHint);
|
|
788
|
+
}
|
|
745
789
|
const createdSession = extractCreatedSession(payload);
|
|
746
790
|
if (createdSession) {
|
|
747
791
|
if (isForkedSessionTitle(createdSession.title)) {
|
|
@@ -777,7 +821,7 @@ export const createSessionTitleRuntime = ({
|
|
|
777
821
|
initialRefreshes.add(status.sessionId);
|
|
778
822
|
armTimer(status.sessionId, status.directory || directoryHint, 0);
|
|
779
823
|
}
|
|
780
|
-
} else if (!initialRefreshes.has(status.sessionId)) {
|
|
824
|
+
} else if (!initialRefreshes.has(status.sessionId) && !forkFirstRefreshes.has(status.sessionId)) {
|
|
781
825
|
clearTimer(status.sessionId);
|
|
782
826
|
}
|
|
783
827
|
return;
|
|
@@ -797,6 +841,7 @@ export const createSessionTitleRuntime = ({
|
|
|
797
841
|
userMessage.sessionId,
|
|
798
842
|
userMessage.directory || directoryHint,
|
|
799
843
|
userMessage.createdAt,
|
|
844
|
+
userMessage.initialRecord,
|
|
800
845
|
).catch((error) => {
|
|
801
846
|
console.warn('[session-title] failed to record user activity:', error?.message || error);
|
|
802
847
|
});
|
|
@@ -804,7 +849,7 @@ export const createSessionTitleRuntime = ({
|
|
|
804
849
|
// session settles. Only a message created after the timer was armed
|
|
805
850
|
// means the user actually moved on.
|
|
806
851
|
const armed = timers.get(userMessage.sessionId);
|
|
807
|
-
if (armed && !initialRefreshes.has(userMessage.sessionId) && userMessage.createdAt >= armed.armedAt) {
|
|
852
|
+
if (armed && !initialRefreshes.has(userMessage.sessionId) && !forkFirstRefreshes.has(userMessage.sessionId) && userMessage.createdAt >= armed.armedAt) {
|
|
808
853
|
clearTimer(userMessage.sessionId);
|
|
809
854
|
}
|
|
810
855
|
}
|
|
@@ -812,6 +857,7 @@ export const createSessionTitleRuntime = ({
|
|
|
812
857
|
|
|
813
858
|
const stop = () => {
|
|
814
859
|
stopped = true;
|
|
860
|
+
sessionAccess?.stop?.();
|
|
815
861
|
for (const { timer } of timers.values()) {
|
|
816
862
|
clearTimeout(timer);
|
|
817
863
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
|
|
2
|
-
import { projectSessionWithHostMetadata } from '../session-metadata/session-projection.js';
|
|
2
|
+
import { projectSessionWithHostMetadata, readSessionTitleAuthority } from '../session-metadata/session-projection.js';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { projectSessionMessageRecords } from '../session-turn-pages/session-message-projection.js';
|
|
4
5
|
|
|
5
6
|
/** Title reads use OpenCode; title-refresh metadata and its notifications belong to the Host. */
|
|
@@ -8,8 +9,15 @@ export function createTitleSessionAccess({
|
|
|
8
9
|
getOpenCodeAuthHeaders,
|
|
9
10
|
readSessionMetadata,
|
|
10
11
|
persistSessionMetadata,
|
|
12
|
+
mutateSessionMetadata,
|
|
13
|
+
listSessionMetadata = async () => ({}),
|
|
11
14
|
publishSession,
|
|
12
15
|
}) {
|
|
16
|
+
const pending = new Map();
|
|
17
|
+
let stopped = false;
|
|
18
|
+
let recovery = null;
|
|
19
|
+
let recoveryTimer = null;
|
|
20
|
+
let recoveryFailures = 0;
|
|
13
21
|
const client = () => makeOpenCodeV2Client({
|
|
14
22
|
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
15
23
|
authHeaders: getOpenCodeAuthHeaders(),
|
|
@@ -17,23 +25,120 @@ export function createTitleSessionAccess({
|
|
|
17
25
|
const options = () => ({ signal: AbortSignal.timeout(30_000) });
|
|
18
26
|
const get = async (sessionID) => {
|
|
19
27
|
const session = await client().session.get({ sessionID }, options());
|
|
20
|
-
|
|
28
|
+
const metadata = await readSessionMetadata(sessionID);
|
|
29
|
+
const authority = readSessionTitleAuthority(sessionID, metadata);
|
|
30
|
+
if (authority && authority.title !== session.title) reconcile(sessionID);
|
|
31
|
+
return projectSessionWithHostMetadata(session, metadata);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Coalesce only pending work; settled sessions retain no in-memory entry.
|
|
35
|
+
const reconcile = (sessionID, directory = '') => {
|
|
36
|
+
if (stopped || !sessionID) return;
|
|
37
|
+
const existing = pending.get(sessionID);
|
|
38
|
+
if (existing) { existing.dirty = true; return; }
|
|
39
|
+
const state = { dirty: false, timer: null, failures: 0, repaired: false };
|
|
40
|
+
pending.set(sessionID, state);
|
|
41
|
+
const run = async () => {
|
|
42
|
+
state.dirty = false;
|
|
43
|
+
try {
|
|
44
|
+
const metadata = await readSessionMetadata(sessionID);
|
|
45
|
+
const authority = readSessionTitleAuthority(sessionID, metadata);
|
|
46
|
+
if (!authority || stopped) { pending.delete(sessionID); return; }
|
|
47
|
+
const session = await client().session.get({ sessionID }, options());
|
|
48
|
+
if (stopped) return;
|
|
49
|
+
if (session.title !== authority.title) {
|
|
50
|
+
await client().session.update({ sessionID, title: authority.title }, options());
|
|
51
|
+
state.repaired = true;
|
|
52
|
+
// Always verify after a write, even if its echo was lost or reordered.
|
|
53
|
+
state.dirty = true;
|
|
54
|
+
} else {
|
|
55
|
+
const latest = await readSessionMetadata(sessionID);
|
|
56
|
+
if (readSessionTitleAuthority(sessionID, latest)?.revision !== authority.revision) {
|
|
57
|
+
state.dirty = true;
|
|
58
|
+
} else if (state.repaired && !stopped) {
|
|
59
|
+
state.repaired = false;
|
|
60
|
+
await publishSession(projectSessionWithHostMetadata(session, latest), directory);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
state.failures = 0;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (Number(error?.status ?? error?.statusCode) === 404 || error?._tag === 'SessionNotFoundError') {
|
|
66
|
+
pending.delete(sessionID);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
state.failures += 1;
|
|
70
|
+
state.dirty = true;
|
|
71
|
+
if (state.failures === 1) console.warn('[session-title] reconciliation failed:', error?.message || error);
|
|
72
|
+
} finally {
|
|
73
|
+
if (stopped) { pending.delete(sessionID); return; }
|
|
74
|
+
if (pending.get(sessionID) !== state) return;
|
|
75
|
+
if (state.dirty) {
|
|
76
|
+
state.timer = setTimeout(run, state.failures ? Math.min(60_000, 1000 * 2 ** Math.min(state.failures - 1, 6)) : 0);
|
|
77
|
+
state.timer.unref?.();
|
|
78
|
+
} else pending.delete(sessionID);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
state.timer = setTimeout(run, 0);
|
|
82
|
+
state.timer.unref?.();
|
|
83
|
+
};
|
|
84
|
+
const reconcileAll = () => {
|
|
85
|
+
if (stopped) return Promise.resolve();
|
|
86
|
+
if (recovery) return recovery;
|
|
87
|
+
clearTimeout(recoveryTimer);
|
|
88
|
+
recovery = (async () => {
|
|
89
|
+
const entries = await listSessionMetadata();
|
|
90
|
+
recoveryFailures = 0;
|
|
91
|
+
for (const [id, metadata] of Object.entries(entries)) {
|
|
92
|
+
if (stopped) return;
|
|
93
|
+
if (!readSessionTitleAuthority(id, metadata)) continue;
|
|
94
|
+
// Serial discovery bounds reconnect reads; only drift queues repair.
|
|
95
|
+
try { await get(id); } catch { reconcile(id); }
|
|
96
|
+
}
|
|
97
|
+
})().catch((error) => {
|
|
98
|
+
recoveryFailures += 1;
|
|
99
|
+
if (!stopped) {
|
|
100
|
+
recoveryTimer = setTimeout(() => { void reconcileAll(); }, Math.min(60_000, 1000 * 2 ** Math.min(recoveryFailures - 1, 6)));
|
|
101
|
+
recoveryTimer.unref?.();
|
|
102
|
+
}
|
|
103
|
+
if (recoveryFailures === 1) console.warn('[session-title] recovery failed:', error?.message || error);
|
|
104
|
+
}).finally(() => { recovery = null; });
|
|
105
|
+
return recovery;
|
|
21
106
|
};
|
|
22
107
|
return {
|
|
23
108
|
get,
|
|
109
|
+
reconcile,
|
|
110
|
+
reconcileAll,
|
|
111
|
+
stop() {
|
|
112
|
+
stopped = true;
|
|
113
|
+
clearTimeout(recoveryTimer);
|
|
114
|
+
for (const state of pending.values()) clearTimeout(state.timer);
|
|
115
|
+
pending.clear();
|
|
116
|
+
},
|
|
24
117
|
async messages(sessionID, _directory, limit) {
|
|
25
118
|
const page = await client().message.list({ sessionID, limit, order: 'desc' }, options());
|
|
26
119
|
if (!Array.isArray(page?.data)) throw new Error('Session title: invalid message page');
|
|
27
120
|
return projectSessionMessageRecords([...page.data].reverse());
|
|
28
121
|
},
|
|
29
122
|
async update(sessionID, directory, patch) {
|
|
123
|
+
if (stopped) return;
|
|
30
124
|
if (typeof patch.title === 'string') {
|
|
125
|
+
const result = await mutateSessionMetadata(sessionID, (metadata) => {
|
|
126
|
+
const current = readSessionTitleAuthority(sessionID, metadata);
|
|
127
|
+
if ((current?.revision ?? null) !== (patch.expectedTitleRevision ?? null)) return { ok: false };
|
|
128
|
+
return { ok: true, patch: { openchamber: {
|
|
129
|
+
titleAuthority: { sessionID, title: patch.title, revision: randomUUID(), source: 'summary' },
|
|
130
|
+
titleRefresh: patch.metadata?.openchamber?.titleRefresh ?? {},
|
|
131
|
+
} } };
|
|
132
|
+
});
|
|
133
|
+
if (!result.committed || stopped) return;
|
|
134
|
+
// Durable intent survives failed writes and process restarts.
|
|
135
|
+
reconcile(sessionID, directory);
|
|
31
136
|
await client().session.update({ sessionID, title: patch.title }, options());
|
|
32
137
|
}
|
|
33
138
|
// Never write the complete metadata snapshot back: goal/archive/ownership
|
|
34
139
|
// may have changed while the title model was running.
|
|
35
140
|
const refresh = patch.metadata?.openchamber?.titleRefresh;
|
|
36
|
-
if (refresh) {
|
|
141
|
+
if (refresh && typeof patch.title !== 'string') {
|
|
37
142
|
await persistSessionMetadata(sessionID, { openchamber: { titleRefresh: refresh } });
|
|
38
143
|
}
|
|
39
144
|
if (typeof patch.title === 'string' || (refresh && Object.hasOwn(refresh, 'isGenerating'))) {
|