@aiworker/web 1.23.0 → 1.23.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.
Files changed (152) hide show
  1. package/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
  2. package/dist/assets/DiffView-DC9RX3MF.js +4 -0
  3. package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
  4. package/dist/assets/FilesView-BFZkuu4O.js +13 -0
  5. package/dist/assets/{GitView-BXsS3jG6.js → GitView-B_x52MHY.js} +4 -4
  6. package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-cmAzUTiw.js} +1 -1
  7. package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-Dehh5afm.js} +1 -1
  8. package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-DgNOqqhE.js} +1 -1
  9. package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DusmMZ_p.js} +2 -2
  10. package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-B8fm1H4m.js} +1 -1
  11. package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-BVr7gJU0.js} +1 -1
  12. package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-DzLt-nel.js} +1 -1
  13. package/dist/assets/PlanView-BmQUYT_z.js +5 -0
  14. package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
  15. package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
  16. package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-cTl_eLIH.js} +6 -6
  17. package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-C_3iYNJQ.js} +2 -2
  18. package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
  19. package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
  20. package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
  21. package/dist/assets/de-DJsQr-p7.js +10 -0
  22. package/dist/assets/es-BuYMlA9g.js +16 -0
  23. package/dist/assets/fr-ePDay33C.js +14 -0
  24. package/dist/assets/index-BD8ILbxF.js +40 -0
  25. package/dist/assets/{index-jXSK5RrB.js → index-n6O3hklM.js} +1 -1
  26. package/dist/assets/index-zhgg-SDz.css +1 -0
  27. package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-GMdPz-71.js} +1 -1
  28. package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-BVuH1zH9.js} +1 -1
  29. package/dist/assets/ja-BzR9xOY_.js +16 -0
  30. package/dist/assets/{ko-idhXGF8j.js → ko-BEjHAm14.js} +3 -3
  31. package/dist/assets/main-BydQ1tr_.js +930 -0
  32. package/dist/assets/{main-CzdPuL0s.js → main-D3ZDQglc.js} +2 -2
  33. package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-Cb2MdZnV.js} +3 -3
  34. package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-d01k801U.js} +2 -2
  35. package/dist/assets/{mobile-mvxTo055.js → mobile-D7VV-Ufz.js} +2 -2
  36. package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
  37. package/dist/assets/{pl-CGZWUqZJ.js → pl-rQmpxNIF.js} +3 -3
  38. package/dist/assets/pt-BR-CorbsSW-.js +16 -0
  39. package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
  40. package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
  41. package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
  42. package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
  43. package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-BzXUjlby.js} +1 -1
  44. package/dist/assets/surface-COKb77Ua.js +1 -0
  45. package/dist/assets/tr-CoAMXPtI.js +7 -0
  46. package/dist/assets/uk-YlAX0rRU.js +16 -0
  47. package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-Cwyhjfl5.js} +153 -155
  48. package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
  49. package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-DDkpBEU8.js} +2 -2
  50. package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-CwjoWuC4.js} +1 -1
  51. package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
  52. package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
  53. package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
  54. package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
  55. package/dist/assets/{web-components-z7hfnS8n.js → web-components-LV3vjTxy.js} +1 -1
  56. package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
  57. package/dist/assets/zh-TW-CQKaCayr.js +16 -0
  58. package/dist/index.html +4 -4
  59. package/dist/mini-chat.html +4 -4
  60. package/dist/mobile.html +4 -4
  61. package/package.json +1 -1
  62. package/server/index.js +3 -13
  63. package/server/lib/agent-memory/runtime.js +7 -2
  64. package/server/lib/agent-memory/runtime.test.js +22 -0
  65. package/server/lib/event-stream/DOCUMENTATION.md +3 -0
  66. package/server/lib/event-stream/global-hub.js +29 -4
  67. package/server/lib/event-stream/global-hub.test.js +22 -0
  68. package/server/lib/event-stream/global-ws-bridge.js +15 -18
  69. package/server/lib/event-stream/protocol.js +21 -7
  70. package/server/lib/event-stream/runtime.js +9 -6
  71. package/server/lib/event-stream/runtime.test.js +60 -0
  72. package/server/lib/git/DOCUMENTATION.md +10 -1
  73. package/server/lib/git/routes.js +21 -8
  74. package/server/lib/git/routes.test.js +78 -0
  75. package/server/lib/git/service.js +159 -4
  76. package/server/lib/git/service.test.js +74 -0
  77. package/server/lib/github/DOCUMENTATION.md +5 -0
  78. package/server/lib/github/routes.js +2 -2
  79. package/server/lib/message-queue/DOCUMENTATION.md +7 -0
  80. package/server/lib/message-queue/runtime.js +13 -0
  81. package/server/lib/message-queue/runtime.test.js +31 -1
  82. package/server/lib/notifications/DOCUMENTATION.md +1 -1
  83. package/server/lib/notifications/emitter-runtime.js +4 -3
  84. package/server/lib/opencode/DOCUMENTATION.md +18 -4
  85. package/server/lib/opencode/env-runtime.js +5 -1
  86. package/server/lib/opencode/env-runtime.test.js +72 -0
  87. package/server/lib/opencode/feature-routes-runtime.js +15 -1
  88. package/server/lib/opencode/lifecycle.js +4 -3
  89. package/server/lib/opencode/lifecycle.test.js +29 -0
  90. package/server/lib/opencode/managed-plugin-config.js +1 -1
  91. package/server/lib/opencode/proxy.js +33 -0
  92. package/server/lib/opencode/settings-helpers.js +3 -0
  93. package/server/lib/opencode/settings-helpers.test.js +11 -1
  94. package/server/lib/opencode/settings-registry.json +3 -0
  95. package/server/lib/opencode/settings-runtime.js +36 -9
  96. package/server/lib/opencode/settings-runtime.test.js +158 -2
  97. package/server/lib/project-context/DOCUMENTATION.md +13 -3
  98. package/server/lib/project-context/runtime.js +8 -2
  99. package/server/lib/project-context/runtime.test.js +54 -0
  100. package/server/lib/projects/DOCUMENTATION.md +2 -1
  101. package/server/lib/projects/project-config.js +38 -9
  102. package/server/lib/projects/project-config.test.js +97 -0
  103. package/server/lib/projects/project-id.js +26 -0
  104. package/server/lib/relay/DOCUMENTATION.md +20 -0
  105. package/server/lib/relay/host-client.js +7 -0
  106. package/server/lib/relay/host-client.test.js +14 -0
  107. package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
  108. package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
  109. package/server/lib/scheduled-tasks/runtime.js +12 -2
  110. package/server/lib/scheduled-tasks/runtime.test.js +53 -0
  111. package/server/lib/session-assist/DOCUMENTATION.md +131 -66
  112. package/server/lib/session-assist/context.js +127 -0
  113. package/server/lib/session-assist/context.test.js +132 -0
  114. package/server/lib/session-assist/prompt.js +54 -0
  115. package/server/lib/session-assist/runtime.js +111 -257
  116. package/server/lib/session-assist/runtime.test.js +203 -0
  117. package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
  118. package/server/lib/walkthrough/pr-source.test.js +21 -0
  119. package/server/lib/walkthrough/pull-request.js +7 -4
  120. package/server/lib/walkthrough/pull-request.test.js +16 -0
  121. package/server/lib/walkthrough/routes.js +19 -0
  122. package/server/lib/walkthrough/routes.test.js +26 -0
  123. package/server/lib/walkthrough/sources.js +13 -2
  124. package/server/opencode-worktree-readiness.test.js +130 -0
  125. package/dist/assets/DiffView-DXjVsEOw.js +0 -4
  126. package/dist/assets/FilesView-CJdG0-wk.js +0 -17
  127. package/dist/assets/PlanView-CJTmOF6I.js +0 -5
  128. package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
  129. package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
  130. package/dist/assets/de-DCrsQ5zS.js +0 -10
  131. package/dist/assets/es-Dwv4YfH6.js +0 -16
  132. package/dist/assets/fr-BCWg4Cnx.js +0 -14
  133. package/dist/assets/index-B68h5S5B.css +0 -1
  134. package/dist/assets/index-DVwMarHI.js +0 -39
  135. package/dist/assets/ja-Bruw3RYC.js +0 -16
  136. package/dist/assets/main-BkyaQhJX.js +0 -930
  137. package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
  138. package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
  139. package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
  140. package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
  141. package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
  142. package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
  143. package/dist/assets/surface-oMvhsog3.js +0 -1
  144. package/dist/assets/tr-BK_KiO2V.js +0 -7
  145. package/dist/assets/uk-CG74xV8N.js +0 -16
  146. package/dist/assets/useGitComparison-DH220WmO.js +0 -1
  147. package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
  148. package/dist/assets/zh-CN-T4AXofZI.js +0 -16
  149. package/dist/assets/zh-TW-Dte40sxp.js +0 -16
  150. package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
  151. package/server/lib/mcp-reconnect/runtime.js +0 -122
  152. package/server/lib/mcp-reconnect/runtime.test.js +0 -140
@@ -1,66 +1,131 @@
1
- # Session Assist
2
-
3
- Server-side watcher that generates a short recap of the agent's last reply
4
- and one suggested user follow-up with the small model
5
- (`lib/small-model`), storing both on the session's metadata under
6
- `metadata.aiworker.assist`.
7
-
8
- ## Flow
9
-
10
- 1. `createSessionAssistRuntime` is a consumer of the server's global SSE
11
- fan-out (`index.js` `onPayload`), riding the same upstream connection as
12
- notifications. Purely event-driven dormant sessions never generate
13
- anything, there is no backfill and no session scanning.
14
- 2. `session.status: idle` arms a 60-second per-session timer; any `busy`/
15
- `retry` status or a user `message.updated` clears it (the "1 minute of
16
- quiet" rule).
17
- 3. On fire: fetch the session (skip sub-agent sessions with `parentID`),
18
- take the LAST exchange only — the final assistant reply plus the user
19
- message it answered (assistant `parentID` → user id) — and call
20
- `generateSmallModelText` with the
21
- session's own provider/model taken from the last assistant message so
22
- the utility call spends the same subscription as the conversation.
23
- `restrictToPreferredProvider` forbids the resolver's global fallback:
24
- conversation content never goes to a provider the user didn't pick for
25
- the session, unless the small model was chosen explicitly (settings
26
- override or opencode config). A resolver 404 is silently skipped.
27
- 4. The requested JSON fields (`recap`, `suggestion`, or both) are clamped and
28
- PATCHed onto the session metadata together with `forMessageID` (the last
29
- assistant message id) and `generatedAt`. Before writing, the session tail is
30
- re-checked (a stale result is dropped) and the metadata is merged from a
31
- fresh session read so concurrent metadata writes made during generation are
32
- preserved.
33
-
34
- ## Settings gate
35
-
36
- `sessionRecapEnabled` and `sessionSuggestionEnabled` in AiWorker settings
37
- (Settings Chat, default on) are hard generation switches checked at fire
38
- time. When both are off, no small-model calls run and nothing is written. When
39
- one is on, the runtime still makes at most one small-model call and asks only
40
- for that field. The UI also hides disabled payload types immediately.
41
-
42
- ## Freshness contract (no clearing writes)
43
-
44
- Clients do not need the payload to be deleted: they render it only while
45
- `assist.forMessageID` still equals the session's last assistant message id
46
- (and the session is idle). Any new message invalidates the payload
47
- everywhere instantly and offline; the next idle cycle overwrites it.
48
-
49
- ## UI consumers (packages/ui)
50
-
51
- - `lib/sessionAssistMetadata.ts` payload parsing.
52
- - `hooks/useSessionAssist.ts` freshness gating + the 1-minute quiet window
53
- for the recap (single timeout to the boundary, no polling).
54
- - `components/chat/SessionRecapSpacer.tsx` renders the recap inside the
55
- fixed-height reserved gap under the last message (height never changes).
56
- - `components/chat/SessionSuggestionChip.tsx` — one tappable suggestion chip
57
- near the composer (desktop chips row + above the mobile pill); hidden as
58
- soon as the composer has any content. Tap fills the input, never sends.
59
-
60
- ## Limitations
61
-
62
- - The watcher lives in the web server, so VS Code (extension-only, no web
63
- server) does not generate assists; it still renders payloads produced by a
64
- web/desktop instance of the same OpenCode server via `session.updated`.
65
- - Metadata payloads ride every `session.updated` event — keep the clamps
66
- (`RECAP_CHAR_LIMIT`, `SUGGESTION_CHAR_LIMIT`) small.
1
+ # Session assist
2
+
3
+ The server generates a short reminder of recent work and an optional next user
4
+ message with Small Model. Results live in `metadata.aiworker.assist` with
5
+ `recap`, `suggestion`, `forMessageID`, and `generatedAt`. The payload shape is
6
+ unchanged; an empty suggestion is a successful outcome.
7
+
8
+ ## Ownership
9
+
10
+ - `runtime.js` owns idle timers, cancellation, provider selection, SDK reads,
11
+ freshness checks, settings gates, and metadata writes.
12
+ - `context.js` reads bounded history and constructs human turns. It removes
13
+ tool payloads and injected prompts before retaining message text.
14
+ - `prompt.js` owns the generation instructions and total input budget.
15
+ - `../small-model/DOCUMENTATION.md` owns provider/auth resolution, generation,
16
+ output limits, and overflow behavior.
17
+
18
+ ## What the model receives
19
+
20
+ Read backward through the official SDK in pages of 50 messages until three
21
+ human turns are covered, history ends, or eight pages have been read. A failed
22
+ page or repeated cursor aborts generation; it is not treated as complete history.
23
+ At the page limit, use fewer available human turns. If the latest answer's human
24
+ request has not been found, skip generation rather than invent its context.
25
+
26
+ Three turns retain the substance behind short commit confirmations without
27
+ bringing an entire old task back into the prompt. This was compared against
28
+ one, five, ten, and full-history contexts on long maintainer sessions. There
29
+ is no full-history cache and no assumed provider prefix-cache behavior.
30
+
31
+ The latest record must be a completed, successful, non-summary assistant answer
32
+ with visible text. Child, archived, and reverted sessions are skipped. A new
33
+ prompt clears the revert boundary before its next idle event.
34
+
35
+ Human turns follow chronological message intervals. OpenCode can insert
36
+ synthetic continuation users during compaction, so a final answer's `parentID`
37
+ need not point directly at the original human request. These continuations stay
38
+ within their human turn; compaction summaries are excluded. An interrupted
39
+ request remains context with its last visible progress explicitly labeled as
40
+ unfinished, rather than being dropped or called a final answer.
41
+ An answer must still reference that human user or one of its continuation
42
+ users; a late answer for an older request cannot be assigned to a newer request.
43
+
44
+ ### Attached context and language
45
+
46
+ The persisted attachment contract is owned by
47
+ `packages/ui/src/lib/messages/contextParts.ts`. Its user-facing Markdown
48
+ formatter is `packages/ui/src/lib/messages/messageMarkdown.ts`.
49
+
50
+ The server projects those persisted parts into model context without importing
51
+ the UI runtime: code comments, file/chat quotes, browser annotations, PR comments,
52
+ checks, terminal selections, and linked GitHub/Linear items remain attached to
53
+ the user turn even when their transport part is synthetic. The OpenCode
54
+ `opencodeComment` mirror is also accepted. Unrecognized synthetic prompts and
55
+ ignored parts are excluded. Malformed attached text fails the generation.
56
+
57
+ Quoted material and the user's own comment are separate blocks. The user's
58
+ authored text is also supplied separately for language selection. Quoted source,
59
+ logs, assistant replies, and injected memory instructions do not choose the
60
+ language. A language-neutral final acknowledgment can use recent authored text.
61
+ The existing Cyrillic/CJK mismatch guard uses that authored sample per field;
62
+ it is not a complete language detector, and it is skipped if no sample exists.
63
+
64
+ ### Input bounds
65
+
66
+ User text is bounded to 8,000 characters and each assistant answer to 16,000.
67
+ Attached quote bodies have their own 4,000-character limit so a large quote
68
+ does not consume the user's comment. Excerpts preserve both ends with an
69
+ explicit omission marker, including the conclusion of a long final report.
70
+
71
+ The complete user prompt is limited to 32,000 characters and the resolved small
72
+ model's input allowance, reserving space for the system prompt. Under pressure,
73
+ drop older whole turns first. If the latest pair itself is too large, excerpt
74
+ both its user request and answer rather than discarding either side. If even
75
+ the minimum prompt cannot fit, skip generation. `onOverflow: 'error'` prevents
76
+ the Small Model service from silently cutting off the instructions. Expected
77
+ context/output-budget failures are quiet and do not write metadata.
78
+
79
+ OpenCode message pages still contain complete tool payloads on the wire. A
80
+ single long turn can therefore require substantial I/O even though its retained
81
+ model context is small. Page/count bounds are not a network-byte quota.
82
+
83
+ ## Generation and lifecycle
84
+
85
+ 1. The server's existing global event fan-out calls `processPayload`. An idle
86
+ event arms the 60-second quiet window. No history scan or startup backfill runs.
87
+ 2. Busy/retry events and newly created user messages clear pending work and
88
+ abort in-flight reads/generation. Re-emitted old user updates do not cancel it.
89
+ 3. One generation runs per session. If a newer quiet window expires while an
90
+ old canceled request is still settling, retain that pending run and start it
91
+ after the old one finishes. Later activity cancels the pending run as well.
92
+ 4. Resolve the small model using the last answer's provider/model and the
93
+ existing explicit settings/config overrides. `restrictToPreferredProvider`
94
+ prevents an implicit cross-provider fallback. Production does not pin the
95
+ experimental model. Generation accepts an abort signal and a 120-second limit.
96
+ 5. Recap describes the substantive work and its current result, including the
97
+ work behind a closing commit or acknowledgment. Suggestion is independent:
98
+ only unfinished requested agent work should produce a sendable user message.
99
+ Completed work, optional offers, or a decision/action belonging to the user
100
+ should return an empty suggestion. This is model judgment, not authorization
101
+ enforcement or a guarantee that every generated field is factually correct.
102
+ 6. Re-read the latest message and fresh session before writing. A moved tail,
103
+ canceled run, changed endpoint/directory, archive, revert, or failed fresh
104
+ read discards the result. Never merge from the old pre-generation metadata.
105
+ 7. Re-check settings, clamp the enabled fields, and merge into fresh metadata.
106
+ The OpenCode update endpoint has no compare-and-set operation; another
107
+ writer after the final read is not guarded atomically.
108
+
109
+ Stopping the runtime clears pending timers/runs and aborts in-flight operations.
110
+ No failed session blocks another session.
111
+
112
+ ## Settings and consumers
113
+
114
+ `sessionRecapEnabled` and `sessionSuggestionEnabled` default on and are checked
115
+ before work and before writing. With both off there are no reads, model calls,
116
+ or writes. With one on, the shared recent context is still available, but only
117
+ that field is requested. An empty suggestion does not erase a valid recap.
118
+
119
+ Clients render an assist only while its `forMessageID` is the last message and
120
+ the session is idle. A new message invalidates it without clearing writes.
121
+
122
+ - `packages/ui/src/lib/sessionAssistMetadata.ts` parses the payload.
123
+ - `packages/ui/src/hooks/useSessionAssist.ts` owns freshness/settings gating.
124
+ - `SessionRecapSpacer` shows the reminder in the reserved gap under the reply.
125
+ - `SessionSuggestionChip` fills the composer; it never sends automatically.
126
+
127
+ Web, Electron, hosted mobile, and Capacitor use the server watcher. VS Code's
128
+ extension-only runtime does not generate assists; shared UI can render payloads
129
+ produced by a server. The background watcher cannot use the browser's message
130
+ store when the UI is closed. Manual AI rename uses that store through
131
+ `SessionMessageLoader`; these are intentionally different retrieval lifecycles.
@@ -0,0 +1,127 @@
1
+ const TURN_LIMIT = 3;
2
+ const PAGE_SIZE = 50;
3
+ const MAX_PAGES = 8;
4
+ const USER_CHAR_LIMIT = 8_000;
5
+ const ANSWER_CHAR_LIMIT = 16_000;
6
+
7
+ // Mirrors the persisted context contract owned by UI lib/messages/contextParts.ts.
8
+ // Read only the fields needed for model context; malformed attached text fails
9
+ // this generation instead of silently dropping the user's comment.
10
+ const QUOTE_FIELDS = new Map([
11
+ ['code-comment', 'code'], ['file-quote', 'quote'], ['chat-quote', 'quote'],
12
+ ['browser-annotation', 'prompt'], ['pr-comment', 'body'], ['pr-check', 'output'],
13
+ ['terminal', 'output'],
14
+ ]);
15
+ const LINK_KINDS = new Set(['github-issue', 'github-pr', 'linear-issue']);
16
+
17
+ export function excerpt(text, limit) {
18
+ if (text.length <= limit) return text;
19
+ const marker = '\n[Content omitted]\n';
20
+ if (limit <= marker.length) return text.slice(0, Math.max(0, limit));
21
+ const head = Math.ceil((limit - marker.length) / 2);
22
+ const tail = limit - marker.length - head;
23
+ return text.slice(0, head) + marker + (tail > 0 ? text.slice(-tail) : '');
24
+ }
25
+
26
+ function attachedText(part) {
27
+ const context = part.metadata?.aiworkerContext;
28
+ if (QUOTE_FIELDS.has(context?.kind)) {
29
+ const source = (context[QUOTE_FIELDS.get(context.kind)] ?? '').trim();
30
+ const authored = (context.text ?? '').trim();
31
+ const label = excerpt((context.fileLabel ?? context.label ?? context.pageUrl ?? context.terminalLabel ?? '').trim(), 300);
32
+ const location = Number.isInteger(context.startLine) ? `, lines ${context.startLine}-${context.endLine ?? context.startLine}` : '';
33
+ const quote = excerpt(source, 4_000).split('\n').map((line) => `> ${line}`).join('\n');
34
+ return { text: `Attached ${context.kind}${label ? ` (${label}${location})` : ''}:\n${quote}\n\nUser comment:\n${excerpt(authored, USER_CHAR_LIMIT)}`, authored };
35
+ }
36
+ if (LINK_KINDS.has(context?.kind)) return { text: excerpt(part.text, USER_CHAR_LIMIT), authored: '' };
37
+ const comment = part.metadata?.opencodeComment;
38
+ if (comment) {
39
+ const authored = comment.comment.trim();
40
+ const source = (comment.preview ?? '').trim();
41
+ const label = excerpt((comment.path ?? '').trim(), 300);
42
+ return { text: `Attached file context (${label}):\n${excerpt(source, 4_000)}\n\nUser comment:\n${excerpt(authored, USER_CHAR_LIMIT)}`, authored };
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** SDK message records are chronological; tool payloads never enter this view. */
48
+ function readMessage({ info, parts }) {
49
+ const blocks = [];
50
+ const authored = [];
51
+ for (const part of parts) {
52
+ if (part.type !== 'text' || part.ignored) continue;
53
+ const attached = info.role === 'user' ? attachedText(part) : null;
54
+ if (attached) {
55
+ blocks.push(attached.text);
56
+ authored.push(attached.authored);
57
+ } else if (!part.synthetic) {
58
+ blocks.push(part.text);
59
+ if (info.role === 'user') {
60
+ // Legacy terminal selections are source material, not a language sample.
61
+ authored.push(part.text.replace(/\n*<terminal_context>\n[\s\S]*?\n<\/terminal_context>\s*$/, ''));
62
+ }
63
+ }
64
+ }
65
+ return {
66
+ id: info.id, role: info.role, parentID: info.parentID,
67
+ providerID: info.providerID, modelID: info.modelID,
68
+ complete: info.role === 'assistant' && info.finish === 'stop' && Boolean(info.time?.completed) && !info.error && !info.summary,
69
+ summary: Boolean(info.summary),
70
+ text: excerpt(blocks.join('\n\n').trim(), info.role === 'user' ? USER_CHAR_LIMIT : ANSWER_CHAR_LIMIT),
71
+ authored: excerpt(authored.filter(Boolean).join('\n\n').trim(), USER_CHAR_LIMIT),
72
+ };
73
+ }
74
+
75
+ function collectTurns(messages) {
76
+ const turns = [];
77
+ let active = null;
78
+ let parents = new Set();
79
+ for (const message of messages) {
80
+ if (message.role === 'user' && message.text) {
81
+ active = { user: message, assistant: null, complete: false };
82
+ parents = new Set([message.id]);
83
+ turns.push(active);
84
+ } else if (active && message.role === 'user') {
85
+ parents.add(message.id);
86
+ } else if (active && message.role === 'assistant' && parents.has(message.parentID) && !message.summary) {
87
+ // Compaction's synthetic continuation users belong to the same human
88
+ // turn. Parent IDs alone would lose its eventual final answer.
89
+ if (message.text) active.assistant = message;
90
+ active.complete = message.complete && Boolean(message.text);
91
+ }
92
+ }
93
+ return turns.slice(-TURN_LIMIT);
94
+ }
95
+
96
+ /** Failure is thrown; null means no eligible final answer within bounded history. */
97
+ export async function loadAssistContext({ readPage, signal }) {
98
+ let messages = [];
99
+ let before;
100
+ const cursors = new Set();
101
+ const ids = new Set();
102
+ for (let pageNumber = 0; pageNumber < MAX_PAGES; pageNumber++) {
103
+ signal.throwIfAborted();
104
+ const page = await readPage({ limit: PAGE_SIZE, before });
105
+ signal.throwIfAborted();
106
+ if (!Array.isArray(page.data)) throw new Error('Session message page is unavailable');
107
+ const older = [];
108
+ for (const record of page.data) {
109
+ if (ids.has(record.info.id)) continue;
110
+ ids.add(record.info.id);
111
+ older.push(readMessage(record));
112
+ }
113
+ messages = older.concat(messages);
114
+ const last = messages.at(-1);
115
+ if (!last?.complete || !last.text) return null;
116
+ const turns = collectTurns(messages);
117
+ const cursor = page.response.headers.get('x-next-cursor');
118
+ if (turns.length === TURN_LIMIT || !cursor || pageNumber === MAX_PAGES - 1) {
119
+ if (!turns.at(-1)?.complete || turns.at(-1).assistant.id !== last.id) return null;
120
+ return { turns, last };
121
+ }
122
+ if (cursors.has(cursor)) throw new Error('Session message pagination made no progress');
123
+ cursors.add(cursor);
124
+ before = cursor;
125
+ }
126
+ return null;
127
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { excerpt, loadAssistContext } from './context.js';
3
+ import { buildAssistPrompt } from './prompt.js';
4
+
5
+ const record = (id, role, text, extra = {}, parts = []) => ({
6
+ info: { id, role, parentID: 'user', finish: 'stop', time: { completed: 1 }, providerID: 'provider', modelID: 'model', ...extra },
7
+ parts: [...(text ? [{ type: 'text', text }] : []), ...parts],
8
+ });
9
+ const pair = (id) => [record(`u${id}`, 'user', `request ${id}`), record(`a${id}`, 'assistant', `answer ${id}`, { parentID: `u${id}` })];
10
+ const page = (data, cursor = '') => ({ data, response: { headers: new Headers({ 'x-next-cursor': cursor }) } });
11
+ const load = (readPage) => loadAssistContext({ readPage, signal: new AbortController().signal });
12
+
13
+ describe('session assist context', () => {
14
+ it('stops paging at three human turns and excludes tools and injected instructions', async () => {
15
+ let calls = 0;
16
+ const records = [...pair(1), ...pair(2), ...pair(3), ...pair(4)];
17
+ records.at(-1).parts.push({ type: 'tool', state: { output: 'TOOL_PAYLOAD' } }, { type: 'text', synthetic: true, text: 'INJECTED_PROMPT' });
18
+ const context = await load(async ({ limit, before }) => {
19
+ calls++;
20
+ expect(limit).toBe(50);
21
+ expect(before).toBeUndefined();
22
+ return page(records, 'more');
23
+ });
24
+ expect(calls).toBe(1);
25
+ expect(context.turns.map((t) => t.user.id)).toEqual(['u2', 'u3', 'u4']);
26
+ expect(context.last.text).toBe('answer 4');
27
+ expect(JSON.stringify(context)).not.toContain('TOOL_PAYLOAD');
28
+ expect(JSON.stringify(context)).not.toContain('INJECTED_PROMPT');
29
+ });
30
+
31
+ it('finds the real user across a page boundary and compaction continuations', async () => {
32
+ const summary = record('summary', 'assistant', 'INTERNAL SUMMARY', { summary: true });
33
+ const continuation = record('continuation', 'user', '', {}, [{ type: 'text', synthetic: true, text: 'Continue' }]);
34
+ const final = record('final', 'assistant', 'All requested work done', { parentID: 'continuation' });
35
+ const context = await load(async ({ before }) => before
36
+ ? page([...pair(1), ...pair(2), record('human', 'user', 'Finish the requested work')])
37
+ : page([summary, continuation, final], 'older'));
38
+ expect(context.last.id).toBe('final');
39
+ expect(context.turns.at(-1).user.id).toBe('human');
40
+ expect(context.turns.at(-1).assistant.id).toBe('final');
41
+ expect(JSON.stringify(context)).not.toContain('INTERNAL SUMMARY');
42
+ });
43
+
44
+ it('retains interrupted requests as progress, not as completed turns', async () => {
45
+ const context = await load(async () => page([
46
+ record('u1', 'user', 'Implement both fixes'),
47
+ record('a1', 'assistant', 'First fix done', { finish: 'tool-calls', parentID: 'u1' }),
48
+ ...pair(2),
49
+ ]));
50
+ expect(context.turns[0].complete).toBe(false);
51
+ expect(context.turns[0].assistant.text).toBe('First fix done');
52
+ expect(context.turns[1].complete).toBe(true);
53
+ });
54
+
55
+ it('keeps synthetic annotations and separates author language from quoted material', async () => {
56
+ const user = record('u', 'user', '', {}, [{
57
+ type: 'text', synthetic: true, text: 'Serialized attachment',
58
+ metadata: { aiworkerContext: { kind: 'chat-quote', quote: 'English quoted source', text: 'Виправ саме це' } },
59
+ }]);
60
+ const context = await load(async () => page([user, record('a', 'assistant', 'Done', { parentID: 'u' })]));
61
+ expect(context.turns[0].user.text).toContain('> English quoted source');
62
+ expect(context.turns[0].user.text).toContain('User comment:\nВиправ саме це');
63
+ expect(context.turns[0].user.authored).toBe('Виправ саме це');
64
+ });
65
+
66
+ it('retains the comment after a large quote and the conclusion after a long answer', async () => {
67
+ const user = record('u', 'user', '', {}, [{ type: 'text', synthetic: true, text: 'attachment', metadata: {
68
+ aiworkerContext: { kind: 'browser-annotation', prompt: 'x'.repeat(30_000), text: 'USER_REQUEST_END' },
69
+ } }]);
70
+ const context = await load(async () => page([user, record('a', 'assistant', `START${'x'.repeat(30_000)}CONCLUSION`, { parentID: 'u' })]));
71
+ expect(context.turns[0].user.text).toContain('USER_REQUEST_END');
72
+ expect(context.last.text).toMatch(/^START/);
73
+ expect(context.last.text).toMatch(/CONCLUSION$/);
74
+ expect(context.last.text.length).toBeLessThanOrEqual(16_000);
75
+ });
76
+
77
+ it('fails explicitly on malformed annotation text instead of losing the comment', async () => {
78
+ const user = record('u', 'user', '', {}, [{ type: 'text', synthetic: true, text: 'attachment', metadata: {
79
+ aiworkerContext: { kind: 'chat-quote', quote: 'source', text: 42 },
80
+ } }]);
81
+ await expect(load(async () => page([user, record('a', 'assistant', 'done')]))).rejects.toThrow();
82
+ });
83
+
84
+ it('does not treat a failed or repeated page as complete history', async () => {
85
+ await expect(load(async () => ({ data: undefined }))).rejects.toThrow('unavailable');
86
+ await expect(load(async () => page(pair(1), 'repeated'))).rejects.toThrow('no progress');
87
+ let calls = 0;
88
+ await expect(load(async () => {
89
+ if (++calls === 2) throw new Error('offline');
90
+ return page(pair(1), 'next');
91
+ })).rejects.toThrow('offline');
92
+ });
93
+
94
+ it('bounds retrieval and refuses to invent a user for an orphaned answer', async () => {
95
+ let calls = 0;
96
+ const context = await load(async () => page([record(`a${++calls}`, 'assistant', 'answer')], `cursor${calls}`));
97
+ expect(calls).toBe(8);
98
+ expect(context).toBeNull();
99
+ });
100
+
101
+ it('skips unfinished, failed, summary, and user tails', async () => {
102
+ for (const tail of [
103
+ record('tail', 'user', 'New request'),
104
+ record('tail', 'assistant', 'Working', { finish: 'tool-calls' }),
105
+ record('tail', 'assistant', 'Failed', { error: { name: 'error' } }),
106
+ record('tail', 'assistant', 'Summary', { summary: true }),
107
+ ]) expect(await load(async () => page([...pair(1), tail]))).toBeNull();
108
+ });
109
+
110
+ it('keeps both sides of the newest exchange within a small model budget', async () => {
111
+ const context = await load(async () => page([
112
+ ...pair(1), ...pair(2), record('latest', 'user', `REQUEST_START${'u'.repeat(8000)}REQUEST_END`),
113
+ record('answer', 'assistant', `ANSWER_START${'a'.repeat(16000)}ANSWER_END`, { parentID: 'latest' }),
114
+ ]));
115
+ for (const budget of [1_000, 4_000, 14_000, 32_000, 1_000_000]) {
116
+ const prompt = buildAssistPrompt(context.turns, { recap: true, suggestion: true }, budget);
117
+ expect(prompt.text.length).toBeLessThanOrEqual(Math.min(budget, 32_000));
118
+ for (const marker of ['REQUEST_START', 'REQUEST_END', 'ANSWER_START', 'ANSWER_END']) expect(prompt.text).toContain(marker);
119
+ }
120
+ expect(buildAssistPrompt(context.turns, { recap: true }, 500)).toBeNull();
121
+ expect(excerpt('long text', 0)).toBe('');
122
+ expect(excerpt('long text', 2)).toBe('lo');
123
+ for (let limit = 0; limit < 60; limit++) expect(excerpt('x'.repeat(100), limit).length).toBeLessThanOrEqual(limit);
124
+ });
125
+
126
+ it('does not assign an older parent\'s late answer to a newer user request', async () => {
127
+ expect(await load(async () => page([
128
+ ...pair(1), record('new-user', 'user', 'A different request'),
129
+ record('late-answer', 'assistant', 'Old work done', { parentID: 'u1' }),
130
+ ]))).toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,54 @@
1
+ import { excerpt } from './context.js';
2
+
3
+ const MAX_PROMPT_CHARS = 32_000;
4
+
5
+ export function buildAssistSystemPrompt({ recap, suggestion }) {
6
+ return [
7
+ `Return exactly one JSON object with string fields: ${[recap ? '"recap"' : '', suggestion ? '"suggestion"' : ''].filter(Boolean).join(', ')}.`,
8
+ recap ? 'recap is a reminder of the actual work accomplished or conclusion reached in the recent conversation. In at most 20 words, name the concrete behavior or subject and its current result. The reader wants to remember WHAT changed or was learned.' : '',
9
+ recap ? 'Write the recap for the person using the app, not for someone reviewing its code. Name the feature and the concrete difference the user will notice. Replace generic "implemented and tested" summaries with what now works differently or what was learned. File paths, internal module names, test counts, and lists of checks belong in the recap only when they are the subject of the user\'s request. Preserve a specific finding or limitation when it changes the meaning of the result.' : '',
10
+ recap ? 'For a closing exchange such as a commit, push, acknowledgment, or thank-you, summarize the substantive work from the preceding answers. Commit bookkeeping, authorship, branch names, and hashes are secondary and usually omitted. A recap saying only that optimizations or changes were committed does not serve this purpose.' : '',
11
+ recap ? 'Use the latest state of that work. Distinguish recommendations from actions already performed, and implementations from verified deployments. Retain the reported conclusion without recalculating detailed lists. Earlier unrelated topics are not part of the recap.' : '',
12
+ suggestion ? 'suggestion is optional. Return "" when the current request is satisfied or continuing requires a user decision. Otherwise return one concise message the user could send to continue specific unfinished work they requested.' : '',
13
+ suggestion ? 'suggestion: return an empty string if the latest request has been satisfied, the next move requires the user\'s decision, or the context does not establish unfinished requested work. Finishing is a normal outcome.' : '',
14
+ suggestion ? 'Otherwise write one concise, specific next message the user can send unchanged to continue the unfinished request. Use the user\'s voice addressing the agent.' : '',
15
+ suggestion ? 'A request to analyze, explain, or recommend is satisfied by that analysis, explanation, or recommendation unless the user also asked for execution. An optional offer, an implementation plan, a caveat about untested platforms, or uncommitted work is not permission for a new task. Do not revive old requests after the user changes topic.' : '',
16
+ 'Language: all requested fields follow the latest user-authored communication, including the user\'s comments on quotes. Ignore the language of the quoted material, code, logs, assistant responses, and these instructions. For a language-neutral acknowledgment use recent user-authored communication. Keep technical names unchanged where useful.',
17
+ recap && suggestion ? 'Keep recap and suggestion independent: recap may carry earlier substantive work forward, while suggestion must be justified by the current request, not by that earlier work.' : '',
18
+ suggestion ? 'Only suggest work the coding agent can perform in the session. If the next action belongs to the user, such as checking their phone, choosing a design, or approving a change, return "". A suggestion is a message sent TO the agent, never a reminder addressed to the user.' : '',
19
+ ].filter(Boolean).join('\n');
20
+ }
21
+
22
+ function renderTurn(turn, index, userText = turn.user.text, answerText = turn.assistant?.text ?? '') {
23
+ return [
24
+ `Turn ${index + 1}${turn.complete ? '' : ' (interrupted before a final response)'}`,
25
+ 'User message with attached context:', userText,
26
+ turn.complete ? 'Assistant final response:' : 'Assistant progress before interruption:', answerText,
27
+ ].join('\n');
28
+ }
29
+
30
+ export function buildAssistPrompt(turns, targets, charBudget) {
31
+ const budget = Math.min(MAX_PROMPT_CHARS, Math.floor(charBudget));
32
+ if (!Number.isFinite(budget) || budget < 1_000 || !turns.length) return null;
33
+ const languageBudget = Math.min(3_600, Math.floor(budget / 5));
34
+ const language = excerpt(turns.map((turn) => excerpt(turn.user.authored, 1_200)).filter(Boolean).join('\n'), languageBudget);
35
+ const header = 'Recent conversation turns, oldest first. Older turns may be omitted.\n\n';
36
+ const requested = [targets.recap ? 'a reminder of the recent substantive work in recap' : '', targets.suggestion ? 'an optional current next step in suggestion' : ''].filter(Boolean).join(', and ');
37
+ const footer = `\n\n--- End of conversation evidence ---\n\nRecent user-authored communication, excluding attached quotes, oldest first:\n\n${language}\n\nReturn ${requested}.`;
38
+ const available = budget - header.length - footer.length;
39
+ const kept = turns.slice();
40
+ let body = kept.map((turn, i) => renderTurn(turn, i)).join('\n\n---\n\n');
41
+ while (body.length > available && kept.length > 1) {
42
+ kept.shift();
43
+ body = kept.map((turn, i) => renderTurn(turn, i)).join('\n\n---\n\n');
44
+ }
45
+ if (body.length > available) {
46
+ const turn = kept[0];
47
+ const textBudget = available - renderTurn(turn, 0, '', '').length;
48
+ if (textBudget < 128) return null;
49
+ const answer = turn.assistant?.text ?? '';
50
+ const userBudget = Math.min(turn.user.text.length, Math.max(Math.floor(textBudget / 3), textBudget - answer.length));
51
+ body = renderTurn(turn, 0, excerpt(turn.user.text, userBudget), excerpt(answer, textBudget - userBudget));
52
+ }
53
+ return { text: header + body + footer, language };
54
+ }