@openchambery/web 1.19.13-beta.3 → 1.19.13-beta.4

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 (48) hide show
  1. package/dist/assets/AssistantView-4rOhnCbj.js +2 -0
  2. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-qyOlOyET.js} +1 -1
  3. package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-DJORbaAh.js} +2 -2
  4. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-Baw1_E_b.js} +4 -1
  5. package/dist/assets/MarkstreamRendererImpl-C3y7cRux.js +3 -0
  6. package/dist/assets/{MobileDetailNavigation-Bfs4CTUh.js → MobileDetailNavigation-Cy-LwJ5M.js} +1 -1
  7. package/dist/assets/{MobileShareBridge-DtwVLkBL.js → MobileShareBridge-CTai3NEM.js} +1 -1
  8. package/dist/assets/{MobileSurface-BmYYdY3g.js → MobileSurface-CYg5Flgz.js} +1 -1
  9. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-CnzvP7OW.js} +1 -1
  10. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-Gerbi-eT.js} +1 -1
  11. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-Czkt-v7v.js} +1 -1
  12. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-C3PKUnFu.js} +1 -1
  13. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-Nv1AVjeU.js} +1 -1
  14. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-eohB6OfO.js} +1 -1
  15. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-CrP1R39C.js} +1 -1
  16. package/dist/assets/{insertionBoundaries-BBn8YW82.js → insertionBoundaries-C8jXd3NT.js} +1 -1
  17. package/dist/assets/{main-phpzFgMN.js → main-Bjgj-x5l.js} +2 -2
  18. package/dist/assets/{main-Cd7NwXx5.js → main-DZg9T7tk.js} +4 -4
  19. package/dist/assets/{miniChat-D6UYe50H.js → miniChat-DUFEC4jl.js} +2 -2
  20. package/dist/assets/{mobile-w2Ed9I5O.js → mobile-BqOSxf04.js} +2 -2
  21. package/dist/assets/{multirun-CYuxJPBX.js → multirun-IUfGqjJa.js} +1 -1
  22. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-B94Ha79_.js} +1 -1
  23. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-DwWmaqPt.js} +2 -2
  24. package/dist/assets/{renderMobileApp-BvIE86_O.js → renderMobileApp-ByS1wjSm.js} +4 -4
  25. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-DhAd_g4W.js} +1 -1
  26. package/dist/assets/{runtimeEndpointReset-lF5S7U4I.js → runtimeEndpointReset-Dk_Xpe6y.js} +1 -1
  27. package/dist/assets/{sessionLookup-CNRDupYq.js → sessionLookup-DZcwnQYW.js} +1 -1
  28. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-BO7HWYlJ.js} +5 -5
  29. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-BAuvqFxV.js} +1 -1
  30. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B9GzC3CE.js} +1 -1
  31. package/dist/assets/{useMobileNavigationStore-DANrWekj.js → useMobileNavigationStore-Dqe-sz0u.js} +1 -1
  32. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-DSAkV_JD.js} +1 -1
  33. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-BW-HxZ9_.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/dist/mini-chat.html +2 -2
  36. package/dist/mobile.html +2 -2
  37. package/package.json +1 -1
  38. package/server/lib/assistants/DOCUMENTATION.md +20 -11
  39. package/server/lib/assistants/assign.js +56 -1
  40. package/server/lib/assistants/assign.test.js +60 -0
  41. package/server/lib/assistants/contact-tools.js +212 -1
  42. package/server/lib/assistants/contact-tools.test.js +151 -0
  43. package/server/lib/assistants/harness.js +56 -5
  44. package/server/lib/assistants/harness.test.js +17 -9
  45. package/server/lib/assistants/service.js +514 -16
  46. package/server/lib/assistants/service.test.js +546 -26
  47. package/dist/assets/AssistantView-DOZPK9oh.js +0 -2
  48. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
package/dist/index.html CHANGED
@@ -552,7 +552,7 @@
552
552
  opacity: 0.72;
553
553
  }
554
554
  </style>
555
- <script type="module" crossorigin src="/assets/main-phpzFgMN.js"></script>
555
+ <script type="module" crossorigin src="/assets/main-Bjgj-x5l.js"></script>
556
556
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
557
557
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
558
558
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -572,7 +572,7 @@
572
572
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-noa4Cdbh.js">
573
573
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
574
574
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
575
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BXf9GQ0Q.js">
575
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DhAd_g4W.js">
576
576
  <link rel="stylesheet" crossorigin href="/assets/src-AA-5nXsF.css">
577
577
  </head>
578
578
  <body class="h-full bg-background text-foreground">
@@ -74,7 +74,7 @@
74
74
  }
75
75
  </style>
76
76
 
77
- <script type="module" crossorigin src="/assets/miniChat-D6UYe50H.js"></script>
77
+ <script type="module" crossorigin src="/assets/miniChat-DUFEC4jl.js"></script>
78
78
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
79
79
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
80
80
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -94,7 +94,7 @@
94
94
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-noa4Cdbh.js">
95
95
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
96
96
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
97
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BXf9GQ0Q.js">
97
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DhAd_g4W.js">
98
98
  <link rel="stylesheet" crossorigin href="/assets/src-AA-5nXsF.css">
99
99
  </head>
100
100
  <body class="h-full bg-background text-foreground">
package/dist/mobile.html CHANGED
@@ -63,7 +63,7 @@
63
63
  })();
64
64
  </script>
65
65
 
66
- <script type="module" crossorigin src="/assets/mobile-w2Ed9I5O.js"></script>
66
+ <script type="module" crossorigin src="/assets/mobile-BqOSxf04.js"></script>
67
67
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
68
68
  <link rel="modulepreload" crossorigin href="/assets/runtime-auth-D66yq6ie.js">
69
69
  <link rel="modulepreload" crossorigin href="/assets/runtime-switch-LvWDU-SL.js">
@@ -83,7 +83,7 @@
83
83
  <link rel="modulepreload" crossorigin href="/assets/terminalApi-noa4Cdbh.js">
84
84
  <link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
85
85
  <link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
86
- <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-BXf9GQ0Q.js">
86
+ <link rel="modulepreload" crossorigin href="/assets/runtimeConfig-DhAd_g4W.js">
87
87
  <link rel="stylesheet" crossorigin href="/assets/src-AA-5nXsF.css">
88
88
  </head>
89
89
  <body class="h-full bg-background text-foreground">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openchambery/web",
3
- "version": "1.19.13-beta.3",
3
+ "version": "1.19.13-beta.4",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./server/index.js",
@@ -8,9 +8,9 @@ admission keeps the worker and confirms the exact message ID before reporting
8
8
  success. Attachment deduplication uses ordered content digests; reordering
9
9
  attachments is a different request.
10
10
 
11
- The in-app Assistant is an OpenChamber-owned **contact**. OpenCode is only the LLM gateway (`POST /api/openchamber/llm/chat/completions`) using already-connected `{providerID, modelID}`. Composer send runs `@earendil-works/pi-agent-core` with `thinkingLevel: 'off'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools (`session.create` PermissionRuleset deny-all + `promptAsync` tools map + hidden `openchamber-llm` agent `permission: { "*": deny }`); file and shell work runs in-process through pi. The contact system prompt includes **full argument schemas** for both pi coding tools and OpenChamber contact tools. Skill directory entries are instructions loaded via `read`, not native/MCP tool names.
11
+ The in-app Assistant is an OpenChamber-owned **contact**. OpenCode is only the LLM gateway (`POST /api/openchamber/llm/chat/completions`) using already-connected `{providerID, modelID}`. Composer send runs `@earendil-works/pi-agent-core` with `thinkingLevel: 'off'`. When the assistant has an effective workspace, the harness attaches pi's four coding tools (`read`, `write`, `edit`, `bash`) via `NodeExecutionEnv` in that directory, applies `defaultPrompt`, and merges skills from `~/.claude/skills`, `~/.agents/skills`, plus the project's `.claude/skills` and `.agents/skills` (project wins on name). OpenChamber API tools remain (`new_conversation`, `clear_chat_history`, `list_projects`, `list_sessions`, `get_assistant_settings`, `update_default_prompt`, `create_assistant`, `schedule_task`, `message_assistant`, `assign_session`). It does not use `SessionPrompt` / `promptAsync` as the conversation engine. The LLM gateway throwaway session still denies OpenCode tools (`session.create` PermissionRuleset deny-all + `promptAsync` tools map + hidden `openchamber-llm` agent `permission: { "*": deny }`); file and shell work runs in-process through pi. The contact system prompt includes **full argument schemas** for both pi coding tools and OpenChamber contact tools. Skill directory entries are instructions loaded via `read`, not native/MCP tool names.
12
12
 
13
- Contact turns retain all eight OpenChamber operation tools alongside the four
13
+ Contact turns retain all ten OpenChamber operation tools alongside the four
14
14
  pi tools. Skill discovery contributes instructions to this combined tool set.
15
15
  Assignment intent includes “建个会话” and “再建个会话”. The single missed-call
16
16
  retry checks for a result from a requested operation tool; a `read` result alone
@@ -28,15 +28,15 @@ and keep their existing error result handling.
28
28
 
29
29
  - `assign_session` is an OpenChamber AgentTool on the contact harness. It creates or reuses a visible OpenCode/OpenChamber session on a **registered project path** (Settings projects / `getAllowedRoots()`), optionally an **existing** Chat worktree/branch via `getWorktrees`, then kicks the coding prompt into that session with `session.create` + `session.promptAsync` (same worker path as `POST /api/openchamber/conversations`).
30
30
  - That worker session is the coder. The contact does not code. Assign sessions are **not** archived Assistant bindings and must not use `assistant-workspaces`.
31
- - **Worker model selection:** optional tool args `providerID` / `modelID` / `model` resolve against the live connected OpenCode catalog (`loadConnectedCatalog` / `GET /provider` + `GET /config/providers`). Explicit selection must match uniquely — missing or ambiguous names fail closed (`model_not_found` / `model_ambiguous`) with **no silent fallback**. Omitting model args keeps the contact assistant's default provider/model for the worker only. Selecting a worker model **never** mutates the contact assistant row.
32
- - Every contact turn injects a **Connected OpenCode models** block (providerID, modelID, name, acceptsImages) so the model can discover worker targets without a ninth contact tool. Still eight OpenChamber contact tools + four pi coding tools.
31
+ - **Worker model selection:** optional tool args `providerID` / `modelID` / `model` resolve against the live connected OpenCode catalog (`loadConnectedCatalog` / `GET /provider` + `GET /config/providers`). Explicit selection must match uniquely — missing or ambiguous names fail closed (`model_not_found` / `model_ambiguous`) with **no silent fallback**. Omitting model args on a **new** worker keeps the contact assistant's default provider/model. **Reusing `sessionID` without an explicit model** follows that session's last model (`session.get` → `session.model` `{ providerID, id|modelID }`, else newest user/assistant `info.model`) when it is still an exact providerID+modelID match in the connected catalog (`source: 'session'`); missing session model, catalog miss, or lookup failure **degrades** to the assistant default (`source: 'assistant'`) and never fails assign. Selecting a worker model **never** mutates the contact assistant row.
32
+ - Every contact turn injects a **Connected OpenCode models** block (providerID, modelID, name, acceptsImages) so the model can discover worker targets without another contact tool. Still ten OpenChamber contact tools + four pi coding tools.
33
33
  - **Current-turn attachments:** server-authoritative `userParts` file parts from this contact turn are forwarded into worker `promptAsync` parts by default (including “just open a session” intents). The model must not base64-encode or invent local paths. History images are **not** unbounded-forwarded. Image parts require a vision-capable catalog entry (`acceptsImages`); otherwise assign fails with `image_not_supported` before create. Sensitive file bodies are never logged.
34
34
  - **Create-then-prompt failure:** if `session.create` succeeds and `promptAsync` fails, assign best-effort `session.delete`s the new worker so empty sessions are not left behind. Reused `sessionID` paths never delete on prompt failure.
35
35
  - Successful `assign_session` returns `terminate: true` so the pi agent loop does **not** auto-follow-up the LLM (prevents a looping model from re-assigning dozens of times in one turn). Prerequisite tools (`list_projects` / `list_sessions`) may still run before assign in the same turn; multi-operation user intents remain ordered **across turns** after a successful assign.
36
36
  - Same contact-turn assign gate (tools instance lifetime only, not cross-turn): identical args after success replay the cached success result (no second worker); different args after success, or parallel mismatched args, fail closed with a validation error. The idempotent key includes worker model selection and current-turn attachment scope. Failure clears the gate so a corrected retry may run. Parallel identical args share one in-flight promise.
37
- - After a successful assign, the service persists the existing session card into the contact transcript (user bubbles first, then the card) and watches that worker session in `assistant_contact_watch`. The user-facing outcome is the session card plus a short confirm bubble.
38
- - Existing OpenCode SSE on the same `globalEventHub` as notifications (`session.idle`, `session.error`, `session.status`, `question.asked`, `permission.asked`) updates **the same card** (`busy` → `complete` / `error` / `question`) and appends one idempotent settle bubble (`oc.settle.*`). Session-goal settle (`emitGoalNotification`) is wired into that same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成.
39
- - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `session.messages`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and appends the existing `oc.settle.complete` bubble. `session.error` or last-assistant `info.error` settles 失败. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete.
37
+ - After a successful assign, the service persists the existing session card into the contact transcript (user bubbles first, then the card) and watches that worker session in `assistant_contact_watch`. The user-facing outcome is the session card plus any short **spoken** preamble; English tool-result confirms (`Opened a coding session.`, `Created assistant…`, `Sent to…`) for card/side-effect tools are **not** written into the transcript.
38
+ - Existing OpenCode SSE on the same `globalEventHub` as notifications (`session.idle`, `session.error`, `session.status`, `question.asked`, `permission.asked`) updates **the same card** (`busy` → `complete` / `error` / `question`). **No canned settle transcript bubbles** (`oc.settle.complete` / `error` / `question` are not written). On **complete or error** when the watch status actually changed, the service schedules an async contact-lane **continuation** (`inContactTurnLane` + `runContactTurn`) per related assistant: no admitted user row (internal `userText` only), active contact turn (green dot / 3-dot), stable resume id `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`, bounded last worker assistant text (~2000 chars; fetch failure still resumes with status only). The contact Agent may re-`assign_session` the same sessionID or summarize in the user's language — the user sees that Agent summary, not 「会话已完成/会话失败」. `question` updates the card only (worker waiting on the user). Session-goal settle (`emitGoalNotification`) uses the same reporter — not a second scheduler. Idle after `session.error` must not rewrite 失败 as 完成 (`changed=false` → no second resume). Same complete status re-reported is idempotent.
39
+ - Restart or a missed `session.idle` cannot leave the watch stuck: boot and the existing 60s reconcile timer `listInFlightWatches` and poll OpenCode `session.get` / `session.messages`. Idle, `time.completed`, or a missing session settles the same card (`busy` → 完成) and triggers the same contact continuation (no `oc.settle.complete` bubble). `session.error` or last-assistant `info.error` settles 失败 + resume once. Transport / non-404 get errors leave the watch in flight (failure is not empty success). One failed watch does not block unrelated watches. Reconcile reuses `reportAssignedSession`, so an error watch is never rewritten as complete. Tombstoned assistants skip resume.
40
40
  - Worker sessions stamp `metadata.openchamber.assigned` (`from: 'contact'`, `assistantID`, `name`). They must **not** use `openchamber.assistant.assistantID` — that marker hides archived Assistant bindings from Chat.
41
41
  - `AssistantDTO.assignedSessionIDs` lists in-flight (`busy`/`question`) watches for assigned worker sessions. Session-card busy stays on the card; it does **not** drive the list green dot.
42
42
  - **Contact active turn (server authoritative):** process-local metadata tracks admission → `queued` → `running` → settled (removed). `AssistantDTO.working` is true only while that assistant has an unsettled contact turn. `AssistantDTO.activeContactTurn` is `{ turnID, messageID, status: 'queued'|'running', admittedAt }` or `null` so APP restart / reconnect can rehydrate list green dots and the contact 3-dot row from the snapshot HTTP GET — local optimistic and SSE preview are temporary only. A server process restart clears in-memory activity (no permanent green; tools are not auto-rerun). Per-assistant lanes isolate turns across assistants/runtimes. Stale `contact-turn-end` for an older `turnID` must not clear a newer active turn.
@@ -53,13 +53,22 @@ and keep their existing error result handling.
53
53
  - `list_projects` refreshes/filters that catalog (`query` optional). `list_sessions` searches the existing session-index (`sessionIndexService.snapshot()`) for sessions in a project (`projectPath` / `projectID`, optional title query). Results are bounded (`sessionID`, `title`, `directory`, `updatedAt`). Index/catalog failure is an error, never an empty success.
54
54
  - After matching a project, open work with `assign_session` (`projectPath` and/or existing `sessionID`).
55
55
 
56
+ **Assistant settings — default prompt read/write (this PR)**
57
+
58
+ - `get_assistant_settings` reads a live Assistant row from SQLite (`output(editable(id))`), not the turn-start `currentAssistant` snapshot. Omit `to`/`name`/`toAssistantID` → **this** contact. Pass `to="OpenCode 配置助手"` (or `toAssistantID`) → that live assistant. `details.settings` is `{ id, name, defaultPrompt, providerID, modelID, agent, variant, mode, workspacePath, enabled }`. Content is a short readable dump; empty `defaultPrompt` is labeled `(empty)`.
59
+ - `update_default_prompt` `{ prompt: string, to?, name?, toAssistantID? }` (prompt required; empty string clears) persists `defaultPrompt` via `updateAssistant(id, { expectedRevision, defaultPrompt })`. Omit target → this contact. Named/id target updates **that** row and does **not** overwrite this contact. This is **Assistant settings** (same field as Settings UI), not a one-shot user message and not `new_conversation` / clear-memory.
60
+ - Unchanged value → idempotent no-op `{ updated:false, unchanged:true }` — no revision bump.
61
+ - `revision_conflict` → re-read revision and retry **once**; a second conflict surfaces via `toolFailure` with `details.error: 'revision_conflict'` (`terminate: true`). Real storage errors are not swallowed.
62
+ - Success → `{ updated:true, defaultPrompt }` with confirm text that the value applies on **later turns** only (current turn system prompt is already built). `terminate: false` (same as `create_assistant` / `schedule_task`) so the model can emit one short confirm bubble.
63
+ - Harness still injects `assistant.defaultPrompt` into the system prompt at turn start; a write mid-turn does not rebuild this turn's prompt. The next `runContactTurn` snapshot reads the new value from DB. UI Settings forms already bind `defaultPrompt` and refresh on `assistants-changed` / revision tip — no UI change required.
64
+
56
65
  **Create assistant / schedule task (this PR)**
57
66
 
58
67
  - `create_assistant` calls the same in-process `createAssistant` (`name`, connected OpenCode `providerID`/`modelID`, `mode: 'continuous'`). It emits an `assistant` card that opens that contact.
59
68
  - `schedule_task` calls in-process `projectConfigRuntime.upsertScheduledTask` with the same payload as `PUT /api/projects/:id/scheduled-tasks` (`name`, `schedule.kind/time/timezone`, `execution.prompt` + provider/model) on a registered project, then best-effort `scheduledTasksRuntime.syncProject`. It emits a `schedule` card.
60
69
  - Successful `schedule_task` also writes an assistants-owned mapping row in `assistants.sqlite` (`assistant_scheduled_task`: assistantID + projectID + taskID). Project-config execution strips unknown fields, so ownership stays out of the task payload. `GET /api/openchamber/assistants/:id/scheduled-tasks` returns those mappings joined with live `listScheduledTasks` records when possible; failed project lookups keep the mapping with `task: null`.
61
- - `CONTACT_SYSTEM_PROMPT` + `formatContactToolsPrompt` + `formatPiCodingPrompt` tell the model to call these from natural language (开新对话 / 找项目 / 现有对话 / 建助理 / 建会话 / 排定时任务 / 给 X 说一声). Catalog lines include each tool's JSON argument schema (pi `write`/`edit` require `path`+`content` / `path`+`edits[{oldText,newText}]`). A reply without the tool call does nothing; never say 已创建 / 已发送 unless the tool returned. Completions are messages-only (no native tools), so `parseContactToolCalls` accepts a fence, a whole-message JSON object, or a bare `{name, arguments}` object anywhere in the reply. Tool results are re-injected as user turns labeled `OpenChamber tool result name=<tool> call=<id>` so multi-step turns keep call/result association.
62
- - If the user asked for `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session` and the first completion has no tool call, the harness runs **one** follow-up completion whose user content is only `emit the fence now, do not claim success.` If still no tool, the turn replies that it could not complete and does not fake a card. No slash commands. No second store.
70
+ - `CONTACT_SYSTEM_PROMPT` + `formatContactToolsPrompt` + `formatPiCodingPrompt` tell the model to call these from natural language (开新对话 / 找项目 / 现有对话 / 查看助手设定·默认提示词 / 改默认提示词·设置人设 / 建助理 / 建会话 / 排定时任务 / 给 X 说一声). Catalog lines include each tool's JSON argument schema (pi `write`/`edit` require `path`+`content` / `path`+`edits[{oldText,newText}]`). A reply without the tool call does nothing; never say 已创建 / 已发送 unless the tool returned. Completions are messages-only (no native tools), so `parseContactToolCalls` accepts a fence, a whole-message JSON object, or a bare `{name, arguments}` object anywhere in the reply. Tool results are re-injected as user turns labeled `OpenChamber tool result name=<tool> call=<id>` so multi-step turns keep call/result association.
71
+ - If the user asked for `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session` and the first completion has no tool call, the harness runs **one** follow-up completion whose user content is only `emit the fence now, do not claim success.` If still no tool, the turn replies that it could not complete and does not fake a card. No slash commands. No second store.
63
72
 
64
73
  **Clear memory vs clear chat history (this PR)**
65
74
 
@@ -136,12 +145,12 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
136
145
 
137
146
  - `POST /api/openchamber/assistants/:id/messages` returns **202** `{ messageID, admitted: true, binding }` as soon as the **user row** is persisted (`status: complete`). It does **not** wait for the LLM turn. Validation errors still fail synchronously with 4xx before admit.
138
147
  - After admit: bump revision + `openchamber:assistants-changed`, register active contact turn (`queued`), broadcast `openchamber:contact-turn-start` `{ assistantID, turnID, messageID, occurredAt }` (`turnID` = client `messageID`), then kick `runContactTurn` asynchronously (serialized per assistant). Lane start marks the turn `running`. Lane start marks the turn `running` and bumps revision. Same `messageID`+payload replays admission once (`replayed: true`) without a second lane run; payload mismatch → `idempotency_conflict`. Failures persist a durable assistant `status: error` bubble for query recovery. Catalog/project list loads use a bounded deadline so the lane cannot hang forever.
139
- - Contact SSE reuses `/api/openchamber/events` (no new endpoint): `openchamber:contact-bubble-delta` `{ assistantID, turnID, bubbleIndex, delta, done, occurredAt }`. Raw completion tokens are **not** painted live — they are often chain-of-thought and would appear then vanish. A short spoken preamble (e.g. 我去找一下) may appear before a tool and is kept. Planning/CoT is stripped. No-tool replies emit each stripped bubble as `{ done:true }` after parse, with a short gap between bubbles so they arrive one beat at a time.
148
+ - Contact SSE reuses `/api/openchamber/events` (no new endpoint): `openchamber:contact-bubble-delta` `{ assistantID, turnID, bubbleIndex, delta, done, occurredAt }`. Raw completion tokens are **not** painted live — they are often chain-of-thought and would appear then vanish. A short spoken preamble (e.g. 我去找一下) may appear before a tool and is kept. Planning/CoT is stripped. **Transcript bubbles:** confirm-only tools (`new_conversation` / `clear_chat_history` / `get_assistant_settings` / `update_default_prompt`) may use tool result text as the bubble; card/side-effect tools (`assign_session` / `create_assistant` / `schedule_task` / `message_assistant`) keep spoken only — never stack spoken + English toolText, and never paint card-tool English confirms when a card is enough. Missed-tool turns keep any existing spoken/assistant text; the English `MISSED_TOOL_FAILURE_BUBBLE` is only when there is no text at all. No-tool replies emit each stripped bubble as `{ done:true }` after parse, with a short gap between bubbles so they arrive one beat at a time.
140
149
  - Turn end: strip/parse/tools (existing), persist **assistant** bubbles + cards only (never re-insert the user row), clear active contact turn, bump + assistants-changed (error paths bump even without assistant rows), broadcast `openchamber:contact-turn-end` `{ assistantID, turnID, status: 'complete'|'error', error?, occurredAt }`. On failure the admitted user row stays; clients must not spin forever. Completing a contact turn also sends the same completion notification path as a normal contact: SMS-style title (assistant name) + body (spoken bubbles), via desktop / UI SSE / push, gated by `notifyOnCompletion`.
141
150
  - In-process tests use `whenContactTurnSettled(messageID)` (not part of the HTTP JSON body).
142
151
  - Completions: public `POST /llm/chat/completions` still rejects `stream:true`. In-process contact harness passes `onTextDelta` + `globalEventHub` through `boundCompletion` → `createChatCompletion` → throwaway generate for real tokens only.
143
152
 
144
- Composer messages carry a client message ID. The service accepts the existing OpenCode delivery parts (`{ type: 'text' }` and `{ type: 'file', mime, url, filename? }` data URLs — not a second attachment store), extracts text (`[attachment]` when the send is file-only), admits the user at 202, then runs the contact harness asynchronously (`runContactTurn` / pi-agent-core, thinking off, pi `read` / `write` / `edit` / `bash` in the assistant workspace, OpenChamber tools `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session`, merged `.agents`/`.claude` skills, plus the registered-projects catalog each turn) through the OpenChamber completions gateway (public HTTP non-streaming; in-process `onTextDelta` for contact SSE). Assistant bubbles and tool cards persist at turn end. File parts survive `GET /:id/contact/messages`. The harness forwards those file parts to the completions gateway. Image data URLs reach `promptAsync` only when the connected catalog marks the model as vision-capable; otherwise generate keeps the `[image: …]` description and text-file bytes so a non-vision model cannot stall the contact turn. Gateway 502 responses include `{ error, message }` so the composer can show the OpenCode error string. OpenCode `promptAsync` is not the contact conversation engine; assign uses it only on the **worker** session. Share and queued delivery still use it on Assistant bindings. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
153
+ Composer messages carry a client message ID. The service accepts the existing OpenCode delivery parts (`{ type: 'text' }` and `{ type: 'file', mime, url, filename? }` data URLs — not a second attachment store), extracts text (`[attachment]` when the send is file-only), admits the user at 202, then runs the contact harness asynchronously (`runContactTurn` / pi-agent-core, thinking off, pi `read` / `write` / `edit` / `bash` in the assistant workspace, OpenChamber tools `new_conversation` / `clear_chat_history` / `list_projects` / `list_sessions` / `get_assistant_settings` / `update_default_prompt` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session`, merged `.agents`/`.claude` skills, plus the registered-projects catalog each turn) through the OpenChamber completions gateway (public HTTP non-streaming; in-process `onTextDelta` for contact SSE). Assistant bubbles and tool cards persist at turn end. File parts survive `GET /:id/contact/messages`. The harness forwards those file parts to the completions gateway. Image data URLs reach `promptAsync` only when the connected catalog marks the model as vision-capable; otherwise generate keeps the `[image: …]` description and text-file bytes so a non-vision model cannot stall the contact turn. Gateway 502 responses include `{ error, message }` so the composer can show the OpenCode error string. OpenCode `promptAsync` is not the contact conversation engine; assign uses it only on the **worker** session. Share and queued delivery still use it on Assistant bindings. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
145
154
 
146
155
  `GET /api/openchamber/assistants/:assistantID/messages?before=&limit=` returns archived and current bindings as `{ entries: [{ sessionID, directory, info, parts }], nextCursor, complete }`. It reads assistant-owned mirror rows in chronological session and message order, pages toward older rows with an opaque stable cursor, and preserves the raw OpenCode `info` and `part` JSON. `directory` is the effective workspace for that source session and is nullable for legacy archived rows with an unknown workspace. `complete === (nextCursor === null)` marks arrival at the oldest available history. The service reads the latest persisted `limit + 1` rows first, then demand-backfills one page at a time from the newest incomplete archived session covered by the cursor; a request performs at most three upstream pages and reuses persisted pages without upstream access. Backfill page commits are independent. Each `session.messages` page attempt retries only transient 5xx/network-class SDK failures up to three times with short backoff; persistent failures still throw `upstream_error`. An authoritative OpenCode 404 for an archived or old binding marks that session's backfill complete via the `session-missing` reducer path, deletes only its uncovered event mirrors, preserves covered/admitted rows, and continues scanning other incomplete sessions so one deleted session cannot permanently block Assistant history. Successful backfill pages upsert authoritative rows without clearing provisional mirrors missing from that snapshot. Concurrent ensure that replaces a missing current binding archives the old ID and creates a new one; 404 completion never deletes covered history under either identity. A required-page failure surfaces as an upstream error only when the current request's `pageRows()` is empty; when the current range already has covered or provisional rows, the service returns that partial result and keeps the incomplete cursor retryable. Provisional event mirrors stay readable as `covered=0` fallbacks until a shrink-whitelist signal or a later authoritative upsert elevates them. Current-session live sync overrides the matching SQLite message identity in the UI.
147
156
 
@@ -262,17 +262,56 @@ export function hasAssignImageParts(parts = []) {
262
262
  return sanitizeAssignFileParts(parts).some((part) => String(part.mime).toLowerCase().startsWith('image/'));
263
263
  }
264
264
 
265
+ /**
266
+ * Normalize a session/message model object to { providerID, modelID }.
267
+ * Accepts `{ providerID, id }` or `{ providerID, modelID }`. Lookup failures stay null.
268
+ */
269
+ export function normalizeAssignSessionModel(model) {
270
+ if (!model || typeof model !== 'object' || Array.isArray(model)) return null;
271
+ const providerID = trim(model.providerID, 256);
272
+ const modelID = trim(model.modelID, 256) || trim(model.id, 256);
273
+ if (!providerID || !modelID) return null;
274
+ return { providerID, modelID };
275
+ }
276
+
277
+ /**
278
+ * Extract the last worker model from a session.get payload and optional messages list.
279
+ * Priority: session.model → newest user/assistant info.model. Never throws.
280
+ */
281
+ export function extractAssignSessionModel({ session = null, messages = null } = {}) {
282
+ const payload = session?.data && typeof session.data === 'object' && !Array.isArray(session.data)
283
+ ? session.data
284
+ : session;
285
+ const fromSession = normalizeAssignSessionModel(payload?.model);
286
+ if (fromSession) return fromSession;
287
+
288
+ const rows = Array.isArray(messages)
289
+ ? messages
290
+ : (Array.isArray(messages?.data) ? messages.data : []);
291
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
292
+ const row = rows[index];
293
+ const info = row?.info && typeof row.info === 'object' ? row.info : row;
294
+ const role = info?.role || row?.role;
295
+ if (role !== 'user' && role !== 'assistant') continue;
296
+ const hit = normalizeAssignSessionModel(info?.model || row?.model);
297
+ if (hit) return hit;
298
+ }
299
+ return null;
300
+ }
301
+
265
302
  /**
266
303
  * Resolve worker model for assign.
267
- * - No explicit selection → assistant default (no catalog required).
304
+ * - No explicit selection → reused session model (catalog match, source session) else assistant default.
268
305
  * - Explicit selection → must resolve uniquely against the connected catalog; never silent fallback.
269
306
  * - Provided but illegal/blank/overlong/conflicting fields fail closed (validation_error).
307
+ * - Session model missing/not-in-catalog degrades to assistant default (never fails assign).
270
308
  */
271
309
  export function resolveAssignWorkerModel({
272
310
  providerID,
273
311
  modelID,
274
312
  model,
275
313
  fallback = {},
314
+ sessionModel = null,
276
315
  catalog = null,
277
316
  } = {}) {
278
317
  const explicitProvider = requireProvidedString(providerID, { field: 'providerID', max: 256 });
@@ -284,6 +323,21 @@ export function resolveAssignWorkerModel({
284
323
  const fallbackModel = trim(fallback.modelID, 256);
285
324
 
286
325
  if (!hasExplicit) {
326
+ const session = normalizeAssignSessionModel(sessionModel);
327
+ if (session && catalog && Array.isArray(catalog.models)) {
328
+ const hit = catalog.models.find((item) => (
329
+ item?.providerID === session.providerID && item?.modelID === session.modelID
330
+ ));
331
+ if (hit) {
332
+ return {
333
+ providerID: session.providerID,
334
+ modelID: session.modelID,
335
+ source: 'session',
336
+ name: typeof hit.name === 'string' ? hit.name : null,
337
+ acceptsImages: hit.acceptsImages === true ? true : hit.acceptsImages === false ? false : null,
338
+ };
339
+ }
340
+ }
287
341
  if (!fallbackProvider || !fallbackModel) {
288
342
  throw new AssignError(ASSIGN_CODES.VALIDATION, 'Assistant is missing a connected provider/model.');
289
343
  }
@@ -534,6 +588,7 @@ export async function assignSession(input = {}) {
534
588
  providerID: assistant.providerID,
535
589
  modelID: assistant.modelID,
536
590
  },
591
+ sessionModel: input.sessionModel,
537
592
  catalog: input.catalog,
538
593
  });
539
594
  }
@@ -8,9 +8,11 @@ import {
8
8
  assignSession,
9
9
  attachmentScopeKey,
10
10
  buildAssignParts,
11
+ extractAssignSessionModel,
11
12
  hasAssignImageParts,
12
13
  isAmbiguousPromptFailure,
13
14
  isManagedAssistantWorkspace,
15
+ normalizeAssignSessionModel,
14
16
  requireProvidedString,
15
17
  resolveAssignDirectory,
16
18
  resolveAssignWorkerModel,
@@ -84,6 +86,64 @@ describe('requireProvidedString / resolveAssignWorkerModel', () => {
84
86
  });
85
87
  });
86
88
 
89
+ it('prefers a catalog-matched session model over the assistant default', () => {
90
+ expect(normalizeAssignSessionModel({ providerID: 'xai', id: 'grok-4.6' })).toEqual({
91
+ providerID: 'xai',
92
+ modelID: 'grok-4.6',
93
+ });
94
+ expect(extractAssignSessionModel({
95
+ session: { model: { providerID: 'xai', id: 'grok-4.6' } },
96
+ })).toEqual({ providerID: 'xai', modelID: 'grok-4.6' });
97
+ expect(extractAssignSessionModel({
98
+ messages: [
99
+ { info: { role: 'user', model: { providerID: 'openai', modelID: 'gpt-4o' } } },
100
+ { info: { role: 'assistant', model: { providerID: 'xai', id: 'grok-4.6' } } },
101
+ ],
102
+ })).toEqual({ providerID: 'xai', modelID: 'grok-4.6' });
103
+ expect(resolveAssignWorkerModel({
104
+ sessionModel: { providerID: 'xai', modelID: 'grok-4.6' },
105
+ fallback: { providerID: 'p', modelID: 'm' },
106
+ catalog,
107
+ })).toMatchObject({
108
+ providerID: 'xai',
109
+ modelID: 'grok-4.6',
110
+ source: 'session',
111
+ acceptsImages: true,
112
+ });
113
+ });
114
+
115
+ it('degrades to the assistant model when the session model is missing from catalog', () => {
116
+ expect(resolveAssignWorkerModel({
117
+ sessionModel: { providerID: 'missing', modelID: 'gone' },
118
+ fallback: { providerID: 'p', modelID: 'm' },
119
+ catalog,
120
+ })).toEqual({
121
+ providerID: 'p',
122
+ modelID: 'm',
123
+ source: 'assistant',
124
+ name: null,
125
+ acceptsImages: null,
126
+ });
127
+ expect(resolveAssignWorkerModel({
128
+ sessionModel: { providerID: 'xai', modelID: 'grok-4.6' },
129
+ fallback: { providerID: 'p', modelID: 'm' },
130
+ catalog: null,
131
+ }).source).toBe('assistant');
132
+ });
133
+
134
+ it('keeps explicit model fail-closed over a session model', () => {
135
+ expect(resolveAssignWorkerModel({
136
+ model: 'openai/gpt-4o',
137
+ sessionModel: { providerID: 'xai', modelID: 'grok-4.6' },
138
+ fallback: { providerID: 'p', modelID: 'm' },
139
+ catalog,
140
+ })).toMatchObject({
141
+ providerID: 'openai',
142
+ modelID: 'gpt-4o',
143
+ source: 'explicit',
144
+ });
145
+ });
146
+
87
147
  it('resolves explicit provider/model against the connected catalog', () => {
88
148
  expect(resolveAssignWorkerModel({
89
149
  model: 'xai/grok-4.6',