@openchambery/web 1.19.7 → 1.19.8-beta.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/dist/assets/{AssistantView-nVF0kdFh.js → AssistantView-DkXW9Fzq.js} +1 -1
- package/dist/assets/{DiagramView-B2UtQ8mw.js → DiagramView-C8MZY0fA.js} +1 -1
- package/dist/assets/MarkdownRenderer-cxYw8-8G.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-Cslh4XN_.js → MarkdownRendererImpl-B39nwiZX.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-Ymm12x6J.js → MarkstreamRendererImpl-CdYqdejg.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-DD29ENgU.js → MobileDetailNavigation-D6ri-ePj.js} +1 -1
- package/dist/assets/{MobileShareBridge-CrTe_QND.js → MobileShareBridge-HRTN71wg.js} +1 -1
- package/dist/assets/MobileSurface-D2yHjnZw.js +2 -0
- package/dist/assets/{MultiRunWindow-jG5dCEMH.js → MultiRunWindow-CTRgfYQD.js} +1 -1
- package/dist/assets/{SettingsView-UKlEewdq.js → SettingsView-ImI02YSp.js} +1 -1
- package/dist/assets/{SettingsWindow-DO1aAplT.js → SettingsWindow-DWo2lUaN.js} +1 -1
- package/dist/assets/{TerminalView-BG1UPLsz.js → TerminalView-BseZ5dR-.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CWr5eVTK.js → ToolOutputDialog-BIK3cPqo.js} +1 -1
- package/dist/assets/{appThemeRegistry-BI5drVpC.js → appThemeRegistry-4_bwYAWt.js} +1 -1
- package/dist/assets/{gitApi-D9hFlE3T.js → gitApi-m09MWmzE.js} +1 -1
- package/dist/assets/{insertionBoundaries-D2L7D0Oe.js → insertionBoundaries-B0fzEL22.js} +1 -1
- package/dist/assets/{main-BRedOs_V.js → main-BYX7_XVg.js} +4 -4
- package/dist/assets/{main-DlXpJ0Vw.js → main-BpbNOMNf.js} +2 -2
- package/dist/assets/{miniChat-CBLbFpfy.js → miniChat-CFBgCVoZ.js} +2 -2
- package/dist/assets/{mobile-CoZsWdWk.js → mobile-DcZkxRBz.js} +2 -2
- package/dist/assets/{multirun-BF-I0r2_.js → multirun-CBqJQgS2.js} +1 -1
- package/dist/assets/{projectMeta-2t4L5_4a.js → projectMeta-Ct1qHZhU.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-fl7O3iHy.js → renderElectronMiniChatApp-DxmxYdiE.js} +2 -2
- package/dist/assets/{renderMobileApp-CKYDwf4U.js → renderMobileApp-XXw7Fnrr.js} +4 -4
- package/dist/assets/{runtimeConfig-B4eb4ZiU.js → runtimeConfig-CcIqxMFB.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BHuTYyzc.js → runtimeEndpointReset-CU1HhJfC.js} +1 -1
- package/dist/assets/{sessionLookup-DNc0ZeR3.js → sessionLookup-CNMNR-yh.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dle8xs5h.js → useAppFontEffects-CBLEH9it.js} +5 -5
- package/dist/assets/{useDesktopWindowControlsLayout-_QAwKcQn.js → useDesktopWindowControlsLayout-DMCmF610.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BSyuAgz6.js → useEffectiveDirectory-CMvw1bLA.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-f8xHJk7C.js → useMobileNavigationStore-Di9IWKfu.js} +1 -1
- package/dist/assets/{useSessionAutoCleanup-BHiXaW0J.js → useSessionAutoCleanup-DMyMP_xI.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-DuvXTSm_.js → useWorkerHighlightedLines-uNzY5rmU.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/mini-chat.html +2 -2
- package/dist/mobile.html +2 -2
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +3 -3
- package/server/lib/assistants/contact-tools.js +7 -4
- package/server/lib/assistants/contact-tools.test.js +12 -3
- package/server/lib/assistants/harness.js +117 -93
- package/server/lib/assistants/harness.test.js +90 -0
- package/server/lib/assistants/pi-tools.js +118 -0
- package/server/lib/assistants/pi-tools.test.js +114 -0
- package/dist/assets/MarkdownRenderer-DT_GN1Ex.js +0 -2
- package/dist/assets/MobileSurface-DjxchpUC.js +0 -2
package/dist/mini-chat.html
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
77
|
+
<script type="module" crossorigin src="/assets/miniChat-CFBgCVoZ.js"></script>
|
|
78
78
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
79
79
|
<link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
|
|
80
80
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
|
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-947l7Ra3.js">
|
|
92
92
|
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
93
93
|
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
|
|
94
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
94
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-CcIqxMFB.js">
|
|
95
95
|
<link rel="stylesheet" crossorigin href="/assets/src-DasvfkEg.css">
|
|
96
96
|
</head>
|
|
97
97
|
<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-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-DcZkxRBz.js"></script>
|
|
67
67
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
68
68
|
<link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
|
|
69
69
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
<link rel="modulepreload" crossorigin href="/assets/terminalApi-947l7Ra3.js">
|
|
81
81
|
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
82
82
|
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-DCowZ2ab.js">
|
|
83
|
-
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-
|
|
83
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-CcIqxMFB.js">
|
|
84
84
|
<link rel="stylesheet" crossorigin href="/assets/src-DasvfkEg.css">
|
|
85
85
|
</head>
|
|
86
86
|
<body class="h-full bg-background text-foreground">
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Acceptance criteria
|
|
4
4
|
|
|
5
|
-
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'` and OpenChamber API tools
|
|
5
|
+
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`, `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; file and shell work runs in-process through pi.
|
|
6
6
|
|
|
7
7
|
**Session cards (this PR)**
|
|
8
8
|
|
|
@@ -83,7 +83,7 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
|
|
|
83
83
|
- In-process tests use `whenContactTurnSettled(messageID)` (not part of the HTTP JSON body).
|
|
84
84
|
- 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.
|
|
85
85
|
|
|
86
|
-
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, OpenChamber tools `new_conversation` / `list_projects` / `list_sessions` / `create_assistant` / `schedule_task` / `message_assistant` / `assign_session`
|
|
86
|
+
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` / `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.
|
|
87
87
|
|
|
88
88
|
`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.
|
|
89
89
|
|
|
@@ -93,4 +93,4 @@ Share operations persist operation identity, Assistant identity, payload hash, p
|
|
|
93
93
|
|
|
94
94
|
Schema v2 migrates legacy Inbox bindings once. It prefers the legacy Inbox session, then the latest successful operation or turn session. Schema v5 makes `workspace_path` nullable and migrates stored managed paths under `dataDir/assistant-workspaces/<assistantID>` to `NULL`. Schema v7 adds `mode` (`continuous` | `stateless`, default `continuous`) and preserves legacy `assistant.mode` during Inbox migration. Schema v10 establishes demand-driven history backfill semantics; existing mirror rows remain partial until a complete backfill state is persisted. New records use `assistant_v2` and carry no legacy topic field. Legacy topic, turn, operation, and assistant tables remain migration sources and compatibility backups. Runtime code never reads or writes them. Migration retains OpenCode sessions and messages.
|
|
95
95
|
|
|
96
|
-
Assistant workspaces resolve only under the Assistant workspace root or configured project roots. DTO serialization resolves and creates the managed target through the same containment checks, so project-configured Assistants expose a ready canonical destination for a future managed switch. Managed workspace creation resolves and creates `dataDir/assistant-workspaces/<assistantID>` before a session switch creates its OpenCode session and commits the configuration CAS. Route registration refreshes the authoritative project roots for each request. Each OpenCode session, prompt, message lookup, and skill discovery uses the effective workspace directory, so OpenCode applies its standard workspace skill discovery and configuration.
|
|
96
|
+
Assistant workspaces resolve only under the Assistant workspace root or configured project roots. DTO serialization resolves and creates the managed target through the same containment checks, so project-configured Assistants expose a ready canonical destination for a future managed switch. Managed workspace creation resolves and creates `dataDir/assistant-workspaces/<assistantID>` before a session switch creates its OpenCode session and commits the configuration CAS. Route registration refreshes the authoritative project roots for each request. Each OpenCode session, prompt, message lookup, and skill discovery uses the effective workspace directory, so OpenCode applies its standard workspace skill discovery and configuration. Contact turns additionally merge `~/.claude/skills`, `~/.agents/skills`, and the workspace `.claude/skills` plus `.agents/skills` into the pi system prompt (project wins on name) and append `defaultPrompt`. `skill_roots` is a legacy schema field; migration removes it from `assistant_v2`, and runtime DTOs, input payloads, and writes omit it. `close()` clears the share-retention scheduler and closes SQLite.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AssignError, PROJECT_REQUIRED_MESSAGE } from './assign.js';
|
|
2
2
|
import { createAssistantCardPart, createScheduleCardPart, createSessionCardPart } from './cards.js';
|
|
3
|
+
import { isPiCodingToolName } from './pi-tools.js';
|
|
3
4
|
|
|
4
5
|
const typeboxString = (description) => {
|
|
5
6
|
const schema = { type: 'string', description };
|
|
@@ -34,7 +35,7 @@ export const NEW_CONVERSATION_CONFIRM_BUBBLE = 'Started a new conversation. Prev
|
|
|
34
35
|
const LIST_SESSIONS_LIMIT_DEFAULT = 20;
|
|
35
36
|
const LIST_SESSIONS_LIMIT_MAX = 50;
|
|
36
37
|
|
|
37
|
-
const DENIED_CODING_TOOLS = new Set(['
|
|
38
|
+
const DENIED_CODING_TOOLS = new Set(['glob', 'grep', 'shell', 'find', 'ls', 'powershell']);
|
|
38
39
|
|
|
39
40
|
const FENCE = new RegExp(`\`\`\`${CONTACT_TOOL_FENCE}\\s*([\\s\\S]*?)\`\`\``, 'u');
|
|
40
41
|
export const MISSED_FENCE_RETRY_USER_TEXT = 'emit the fence now, do not claim success.';
|
|
@@ -42,7 +43,7 @@ export const MISSED_TOOL_FAILURE_BUBBLE = 'I could not complete that. No tool ra
|
|
|
42
43
|
|
|
43
44
|
const CREATE_ASSISTANT_INTENT = /建助理|新建[^。\n!]{0,24}助理|创建[^。\n!]{0,24}助理|加一个助理|create (?:an |a new )?assistant|new assistant/iu;
|
|
44
45
|
const SCHEDULE_TASK_INTENT = /排定时任务|排个?定时任务|定时任务|schedule (?:a )?(?:daily )?(?:task|ping)|scheduled task|排个?(?:每日)?(?:任务|ping)/iu;
|
|
45
|
-
const ASSIGN_SESSION_INTENT = /建会话|开会话|开(?:一个)?(?:编码\s*)?(?:session|会话)|open (?:a )?(?:coding )?session|assign_session
|
|
46
|
+
const ASSIGN_SESSION_INTENT = /建会话|开会话|开(?:一个)?(?:编码\s*)?(?:session|会话)|open (?:a )?(?:coding )?session|assign_session/giu;
|
|
46
47
|
const MESSAGE_ASSISTANT_INTENT = /给[^。\n]{1,40}说(?:一声)?|跟[^。\n]{1,24}说(?:一声)?|告诉(?!我)[^。\n]{1,40}|说一声|message (?:the )?(?:assistant|peer)|(?:tell|message)\s+[A-Za-z0-9._-]+|send (?:a )?message to/iu;
|
|
47
48
|
const NEW_CONVERSATION_INTENT = /开新对话|新对话|清空(?:聊天|对话)|clear chat|new conversation|start over/iu;
|
|
48
49
|
const LIST_PROJECTS_INTENT = /找项目|查项目|看看项目|有哪些项目|项目列表|list projects|find project|registered project|which project/iu;
|
|
@@ -387,7 +388,9 @@ export function resolvePeerAssistant(params = {}, assistants = [], currentAssist
|
|
|
387
388
|
}
|
|
388
389
|
|
|
389
390
|
export function formatContactToolsPrompt(tools) {
|
|
390
|
-
const list = Array.isArray(tools)
|
|
391
|
+
const list = Array.isArray(tools)
|
|
392
|
+
? tools.filter((tool) => tool?.name && !DENIED_CODING_TOOLS.has(tool.name) && !isPiCodingToolName(tool.name))
|
|
393
|
+
: [];
|
|
391
394
|
if (list.length === 0) return '';
|
|
392
395
|
return [
|
|
393
396
|
'The user talks in natural language (including Chinese). Never ask them to type slash commands.',
|
|
@@ -395,7 +398,7 @@ export function formatContactToolsPrompt(tools) {
|
|
|
395
398
|
'When they want to find a registered project (找项目 / list projects / "openchamber yee"), call list_projects or use the Registered projects block already in context.',
|
|
396
399
|
'When they want existing conversations in a project (现有对话 / list sessions), call list_sessions.',
|
|
397
400
|
'When they want another assistant (建助理 / create an assistant), call create_assistant.',
|
|
398
|
-
'When they want
|
|
401
|
+
'When they want a separate Chat coding session (建会话 / open a session / 开个新会话), call assign_session after matching the project. File and shell work in this assistant\'s working directory uses read, write, edit, and bash — not assign_session.',
|
|
399
402
|
'When they want a scheduled task (排定时任务 / schedule daily ping), call schedule_task.',
|
|
400
403
|
'When they want to tell another assistant (给 PeerQA 说一声 / message PeerQA), call message_assistant.',
|
|
401
404
|
`Call exactly one tool per reply by emitting one fenced JSON block:`,
|
|
@@ -90,6 +90,8 @@ describe('contact tool protocol', () => {
|
|
|
90
90
|
]);
|
|
91
91
|
expect(detectRequestedContactTools('每天 18:00 排一个 ping 定时任务', tools)).toEqual([SCHEDULE_TASK_TOOL_NAME]);
|
|
92
92
|
expect(detectRequestedContactTools('建会话写一个文件', tools)).toEqual([ASSIGN_SESSION_TOOL_NAME]);
|
|
93
|
+
expect(detectRequestedContactTools('写一个文件', tools)).toEqual([]);
|
|
94
|
+
expect(detectRequestedContactTools('pwd', tools)).toEqual([]);
|
|
93
95
|
expect(detectRequestedContactTools('给 PeerQA 说一声 hello-from-assistant 写好了', tools)).toEqual([
|
|
94
96
|
MESSAGE_ASSISTANT_TOOL_NAME,
|
|
95
97
|
]);
|
|
@@ -103,9 +105,16 @@ describe('contact tool protocol', () => {
|
|
|
103
105
|
expect(detectRequestedContactTools('不要开编码 session', tools)).toEqual([]);
|
|
104
106
|
});
|
|
105
107
|
|
|
106
|
-
it('
|
|
107
|
-
const text = '```openchamber-tool\n{"name":"bash","arguments":{"command":"
|
|
108
|
-
expect(parseContactToolCalls(text, ['bash', ASSIGN_SESSION_TOOL_NAME]).toolCall).
|
|
108
|
+
it('parses bash fences when the pi coding tools are allowed', () => {
|
|
109
|
+
const text = '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```';
|
|
110
|
+
expect(parseContactToolCalls(text, ['bash', ASSIGN_SESSION_TOOL_NAME]).toolCall).toEqual({
|
|
111
|
+
name: 'bash',
|
|
112
|
+
arguments: { command: 'pwd' },
|
|
113
|
+
});
|
|
114
|
+
expect(parseContactToolCalls(
|
|
115
|
+
'```openchamber-tool\n{"name":"glob","arguments":{"pattern":"*"}}\n```',
|
|
116
|
+
['glob', 'bash'],
|
|
117
|
+
).toolCall).toBeNull();
|
|
109
118
|
});
|
|
110
119
|
|
|
111
120
|
it('tells DeepSeek to call tools from natural language, not slash commands', () => {
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
parseContactToolCalls,
|
|
15
15
|
stripContactToolFences,
|
|
16
16
|
} from './contact-tools.js';
|
|
17
|
+
import {
|
|
18
|
+
createPiCodingRuntime,
|
|
19
|
+
formatPiCodingPrompt,
|
|
20
|
+
isPiCodingToolName,
|
|
21
|
+
resolveAssistantCwd,
|
|
22
|
+
} from './pi-tools.js';
|
|
17
23
|
|
|
18
24
|
function createAssistantMessageEventStream() {
|
|
19
25
|
const events = [];
|
|
@@ -63,17 +69,16 @@ function createAssistantMessageEventStream() {
|
|
|
63
69
|
}
|
|
64
70
|
|
|
65
71
|
export const CONTACT_SYSTEM_PROMPT = [
|
|
66
|
-
"You are OpenChamber's in-app assistant — a personable contact
|
|
72
|
+
"You are OpenChamber's in-app assistant — a personable contact who can also work in the user's configured project directory.",
|
|
67
73
|
'Reply in short chat bubbles: a few sentences each, separated by a blank line.',
|
|
68
74
|
'Talk like a person in the user\'s language. One short spoken bubble at a time — never a wall of paragraphs.',
|
|
69
75
|
'Never write chain-of-thought, plans, tool names, or English narration of what you will do. The user never sees thinking.',
|
|
70
76
|
'Do not expose tool traces, Activity, or editor actions.',
|
|
71
|
-
'
|
|
77
|
+
'You have bash, read, write, and edit in the working directory. Use them for pwd, files, and shell. Never say you have no terminal or cannot read files. Ignore any temporary generator workspace in the environment.',
|
|
72
78
|
'Understand natural language in any language, including Chinese: 开新对话 means new_conversation, 找项目 means list_projects, 现有对话 means list_sessions, 建助理 means create_assistant, 建会话 / 开个新会话 means assign_session, 排定时任务 means schedule_task, 给 X 说一声 means message_assistant, 发卡片 means emit a card via those tools — never ask the user to type /card or /dm.',
|
|
73
79
|
'You receive the registered project catalog every turn. You CAN see those projects. Look them up yourself (fuzzy match label/name/path). Never say you cannot see the registered project list. Never ask for a raw filesystem path when a name matches. If the catalog is empty, tell the user to add a project in Settings.',
|
|
74
|
-
'To open coding
|
|
80
|
+
'File and shell work in this working directory uses read, write, edit, and bash. To open a separate Chat coding session: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID.',
|
|
75
81
|
'A reply without the tool call does nothing. Never say 已创建, created, scheduled, or opened unless the tool already returned success.',
|
|
76
|
-
'Assign coding work with assign_session so a real Chat session does the work.',
|
|
77
82
|
].join(' ');
|
|
78
83
|
|
|
79
84
|
const emptyUsage = () => ({
|
|
@@ -418,9 +423,13 @@ const extractContactTurnOutcome = (messages, retried) => {
|
|
|
418
423
|
const cards = extractContactCardsFromMessages(slice);
|
|
419
424
|
const hasTool = contactTurnHasToolResult(slice);
|
|
420
425
|
const spoken = extractSpokenPreamble(slice);
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
426
|
+
const lastToolName = [...slice].reverse().find((message) => message?.role === 'toolResult')?.toolName;
|
|
427
|
+
const coding = isPiCodingToolName(lastToolName);
|
|
428
|
+
const toolText = extractToolResultText(slice);
|
|
429
|
+
const assistant = extractAssistantText(slice);
|
|
430
|
+
const confirm = hasTool && !coding
|
|
431
|
+
? (toolText || assistant)
|
|
432
|
+
: (assistant || (coding ? toolText : ''));
|
|
424
433
|
const parts = [];
|
|
425
434
|
if (spoken) parts.push(spoken);
|
|
426
435
|
if (confirm && confirm !== spoken) parts.push(confirm);
|
|
@@ -428,9 +437,10 @@ const extractContactTurnOutcome = (messages, retried) => {
|
|
|
428
437
|
};
|
|
429
438
|
|
|
430
439
|
/**
|
|
431
|
-
* Thin OpenChamber contact harness: pi-agent-core Agent +
|
|
432
|
-
*
|
|
433
|
-
*
|
|
440
|
+
* Thin OpenChamber contact harness: pi-agent-core Agent + thinkingLevel off.
|
|
441
|
+
* Attaches pi read/write/edit/bash in the assistant workspace plus OpenChamber
|
|
442
|
+
* API tools. Transcript in, completions via streamFn, bubbles and session
|
|
443
|
+
* cards out.
|
|
434
444
|
*
|
|
435
445
|
* Assigned-session settle reuses the assistants event hub plus session-goal
|
|
436
446
|
* `emitGoalNotification` — read-only into the contact transcript.
|
|
@@ -446,106 +456,120 @@ export async function runContactTurn({
|
|
|
446
456
|
onTextDelta = null,
|
|
447
457
|
onBubbleDelta = null,
|
|
448
458
|
globalEventHub = null,
|
|
459
|
+
skillHomeDir,
|
|
449
460
|
AgentImpl = Agent,
|
|
450
461
|
}) {
|
|
451
462
|
const providerID = assistant.providerID;
|
|
452
463
|
const modelID = assistant.modelID;
|
|
464
|
+
const cwd = resolveAssistantCwd(assistant);
|
|
453
465
|
const contactTools = Array.isArray(tools)
|
|
454
|
-
? tools.filter((tool) => tool && typeof tool.name === 'string' && !
|
|
466
|
+
? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name))
|
|
455
467
|
: [];
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
468
|
+
let runtime = null;
|
|
469
|
+
try {
|
|
470
|
+
if (cwd) runtime = await createPiCodingRuntime(cwd, { homeDir: skillHomeDir });
|
|
471
|
+
const codingTools = Array.isArray(runtime?.tools) ? runtime.tools : [];
|
|
472
|
+
const systemPrompt = [
|
|
473
|
+
CONTACT_SYSTEM_PROMPT,
|
|
474
|
+
formatPiCodingPrompt({ cwd: runtime?.cwd, skillsPrompt: runtime?.skillsPrompt }),
|
|
475
|
+
formatRegisteredProjectsPrompt(projects),
|
|
476
|
+
formatContactToolsPrompt(contactTools),
|
|
477
|
+
assistant.defaultPrompt,
|
|
478
|
+
].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');
|
|
479
|
+
const model = createContactModel(providerID, modelID);
|
|
480
|
+
// streamFn receives the model; completions needs providerID/modelID.
|
|
481
|
+
model.name = `${providerID}/${modelID}`;
|
|
465
482
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
483
|
+
const pendingFileParts = [
|
|
484
|
+
...(Array.isArray(history) ? history.flatMap((message) => completionFileParts(message.parts)) : []),
|
|
485
|
+
...completionFileParts(userParts),
|
|
486
|
+
];
|
|
487
|
+
const prior = Array.isArray(history)
|
|
488
|
+
? history.map((message) => (
|
|
489
|
+
message.role === 'assistant'
|
|
490
|
+
? { role: 'assistant', content: [{ type: 'text', text: message.content }], api: model.api, provider: model.provider, model: model.id, usage: emptyUsage(), stopReason: 'stop', timestamp: Date.now() }
|
|
491
|
+
: {
|
|
492
|
+
role: 'user',
|
|
493
|
+
content: message.content,
|
|
494
|
+
...(completionFileParts(message.parts).length > 0 ? { parts: completionFileParts(message.parts) } : {}),
|
|
495
|
+
timestamp: Date.now(),
|
|
496
|
+
}
|
|
497
|
+
))
|
|
498
|
+
: [];
|
|
482
499
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
500
|
+
const agent = new AgentImpl({
|
|
501
|
+
initialState: {
|
|
502
|
+
systemPrompt,
|
|
503
|
+
model,
|
|
504
|
+
thinkingLevel: 'off',
|
|
505
|
+
tools: [...codingTools, ...contactTools],
|
|
506
|
+
messages: prior,
|
|
507
|
+
},
|
|
508
|
+
streamFn: createContactStreamFn(createChatCompletion, {
|
|
509
|
+
pendingFileParts,
|
|
510
|
+
onTextDelta,
|
|
511
|
+
onBubbleDelta,
|
|
512
|
+
globalEventHub,
|
|
513
|
+
bubbleGapMs: 280,
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
499
516
|
|
|
500
|
-
|
|
501
|
-
if (agent.state.errorMessage) {
|
|
502
|
-
const error = new Error(agent.state.errorMessage);
|
|
503
|
-
error.code = 'upstream_error';
|
|
504
|
-
throw error;
|
|
505
|
-
}
|
|
506
|
-
const requested = detectRequestedContactTools(userText, contactTools.map((tool) => tool.name));
|
|
507
|
-
let retried = false;
|
|
508
|
-
if (requested.length > 0 && !contactTurnHasToolResult(agent.state.messages)) {
|
|
509
|
-
retried = true;
|
|
510
|
-
await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
|
|
517
|
+
await agent.prompt(userText);
|
|
511
518
|
if (agent.state.errorMessage) {
|
|
512
519
|
const error = new Error(agent.state.errorMessage);
|
|
513
520
|
error.code = 'upstream_error';
|
|
514
521
|
throw error;
|
|
515
522
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
523
|
+
const requested = detectRequestedContactTools(userText, contactTools.map((tool) => tool.name));
|
|
524
|
+
let retried = false;
|
|
525
|
+
if (requested.length > 0 && !contactTurnHasToolResult(agent.state.messages)) {
|
|
526
|
+
retried = true;
|
|
527
|
+
await agent.prompt(MISSED_FENCE_RETRY_USER_TEXT);
|
|
528
|
+
if (agent.state.errorMessage) {
|
|
529
|
+
const error = new Error(agent.state.errorMessage);
|
|
530
|
+
error.code = 'upstream_error';
|
|
531
|
+
throw error;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (requested.length > 0 && !contactTurnHasToolResult(agent.state.messages)) {
|
|
535
|
+
return {
|
|
536
|
+
text: MISSED_TOOL_FAILURE_BUBBLE,
|
|
537
|
+
bubbles: [MISSED_TOOL_FAILURE_BUBBLE],
|
|
538
|
+
cards: [],
|
|
539
|
+
thinkingLevel: agent.state.thinkingLevel,
|
|
540
|
+
tools: [...agent.state.tools],
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const outcome = extractContactTurnOutcome(agent.state.messages, retried);
|
|
544
|
+
const text = stripContactToolFences(outcome.text);
|
|
545
|
+
if (contactTurnHasSuccessfulReset(agent.state.messages)) {
|
|
546
|
+
const bubbles = confirmBubbleAfterContactReset(splitContactBubbles(text));
|
|
547
|
+
return {
|
|
548
|
+
text: bubbles[0] || NEW_CONVERSATION_CONFIRM_BUBBLE,
|
|
549
|
+
bubbles,
|
|
550
|
+
cards: [],
|
|
551
|
+
reset: true,
|
|
552
|
+
thinkingLevel: agent.state.thinkingLevel,
|
|
553
|
+
tools: [...agent.state.tools],
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
if (!text.trim() && outcome.cards.length === 0) {
|
|
557
|
+
const error = new Error('Assistant returned no text');
|
|
558
|
+
error.code = 'upstream_error';
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
530
561
|
return {
|
|
531
|
-
text
|
|
532
|
-
bubbles,
|
|
533
|
-
cards:
|
|
534
|
-
reset: true,
|
|
562
|
+
text,
|
|
563
|
+
bubbles: splitContactBubbles(text),
|
|
564
|
+
cards: outcome.cards,
|
|
535
565
|
thinkingLevel: agent.state.thinkingLevel,
|
|
536
566
|
tools: [...agent.state.tools],
|
|
537
567
|
};
|
|
568
|
+
} finally {
|
|
569
|
+
try {
|
|
570
|
+
await runtime?.close?.();
|
|
571
|
+
} catch {
|
|
572
|
+
// Workspace shell cleanup must not mask the turn result.
|
|
573
|
+
}
|
|
538
574
|
}
|
|
539
|
-
if (!text.trim() && outcome.cards.length === 0) {
|
|
540
|
-
const error = new Error('Assistant returned no text');
|
|
541
|
-
error.code = 'upstream_error';
|
|
542
|
-
throw error;
|
|
543
|
-
}
|
|
544
|
-
return {
|
|
545
|
-
text,
|
|
546
|
-
bubbles: splitContactBubbles(text),
|
|
547
|
-
cards: outcome.cards,
|
|
548
|
-
thinkingLevel: agent.state.thinkingLevel,
|
|
549
|
-
tools: [...agent.state.tools],
|
|
550
|
-
};
|
|
551
575
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
1
4
|
import { describe, expect, it, vi } from 'vitest'
|
|
2
5
|
import {
|
|
3
6
|
CREATE_ASSISTANT_TOOL_NAME,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
createContactTools,
|
|
10
13
|
} from './contact-tools.js'
|
|
11
14
|
import { createContactStreamFn, runContactTurn } from './harness.js'
|
|
15
|
+
import { PI_CODING_TOOL_NAMES } from './pi-tools.js'
|
|
12
16
|
|
|
13
17
|
describe('createContactStreamFn', () => {
|
|
14
18
|
it('forwards completion text as pi-ai text events', async () => {
|
|
@@ -232,6 +236,92 @@ describe('runContactTurn', () => {
|
|
|
232
236
|
expect(prompt).toHaveBeenCalled()
|
|
233
237
|
})
|
|
234
238
|
|
|
239
|
+
it('applies workspace, defaultPrompt, pi tools, and merged .agents/.claude skills', async () => {
|
|
240
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-ws-'))
|
|
241
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-home-'))
|
|
242
|
+
const skill = path.join(workspace, '.agents', 'skills', 'project-skill')
|
|
243
|
+
const claude = path.join(workspace, '.claude', 'skills', 'claude-skill')
|
|
244
|
+
const globalAgents = path.join(home, '.agents', 'skills', 'global-skill')
|
|
245
|
+
fs.mkdirSync(skill, { recursive: true })
|
|
246
|
+
fs.mkdirSync(claude, { recursive: true })
|
|
247
|
+
fs.mkdirSync(globalAgents, { recursive: true })
|
|
248
|
+
fs.writeFileSync(path.join(skill, 'SKILL.md'), '---\nname: project-skill\ndescription: Project skill\n---\nDo the project thing\n')
|
|
249
|
+
fs.writeFileSync(path.join(claude, 'SKILL.md'), '---\nname: claude-skill\ndescription: Claude project skill\n---\nDo the claude thing\n')
|
|
250
|
+
fs.writeFileSync(path.join(globalAgents, 'SKILL.md'), '---\nname: global-skill\ndescription: Global agents skill\n---\nDo the global thing\n')
|
|
251
|
+
function AgentImpl(options) {
|
|
252
|
+
expect(options.initialState.thinkingLevel).toBe('off')
|
|
253
|
+
expect(options.initialState.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES])
|
|
254
|
+
expect(options.initialState.systemPrompt).toContain(workspace)
|
|
255
|
+
expect(options.initialState.systemPrompt).toContain('Be terse.')
|
|
256
|
+
expect(options.initialState.systemPrompt).toContain('project-skill')
|
|
257
|
+
expect(options.initialState.systemPrompt).toContain('claude-skill')
|
|
258
|
+
expect(options.initialState.systemPrompt).toContain('global-skill')
|
|
259
|
+
expect(options.initialState.systemPrompt).toContain('Never say you have no terminal')
|
|
260
|
+
this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Ready.' }] }] }
|
|
261
|
+
this.prompt = async () => {}
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const result = await runContactTurn({
|
|
265
|
+
assistant: {
|
|
266
|
+
providerID: 'openai',
|
|
267
|
+
modelID: 'gpt-5.2',
|
|
268
|
+
defaultPrompt: 'Be terse.',
|
|
269
|
+
effectiveWorkspacePath: workspace,
|
|
270
|
+
},
|
|
271
|
+
history: [],
|
|
272
|
+
userText: 'pwd',
|
|
273
|
+
createChatCompletion: vi.fn(),
|
|
274
|
+
skillHomeDir: home,
|
|
275
|
+
AgentImpl,
|
|
276
|
+
})
|
|
277
|
+
expect(result.bubbles).toEqual(['Ready.'])
|
|
278
|
+
} finally {
|
|
279
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
280
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('executes bash pwd in the assistant workspace', async () => {
|
|
285
|
+
const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-'))
|
|
286
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-assistant-pwd-home-'))
|
|
287
|
+
let calls = 0
|
|
288
|
+
const createChatCompletion = vi.fn(async ({ body }) => {
|
|
289
|
+
calls += 1
|
|
290
|
+
if (calls === 1) {
|
|
291
|
+
return {
|
|
292
|
+
completion: {
|
|
293
|
+
choices: [{
|
|
294
|
+
message: { content: '```openchamber-tool\n{"name":"bash","arguments":{"command":"pwd"}}\n```' },
|
|
295
|
+
}],
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const last = body.messages.at(-1)
|
|
300
|
+
const content = typeof last?.content === 'string' ? last.content : ''
|
|
301
|
+
return { completion: { choices: [{ message: { content: content || 'done' } }] } }
|
|
302
|
+
})
|
|
303
|
+
try {
|
|
304
|
+
const result = await runContactTurn({
|
|
305
|
+
assistant: {
|
|
306
|
+
providerID: 'openai',
|
|
307
|
+
modelID: 'gpt-5.2',
|
|
308
|
+
defaultPrompt: '',
|
|
309
|
+
effectiveWorkspacePath: workspace,
|
|
310
|
+
},
|
|
311
|
+
history: [],
|
|
312
|
+
userText: 'pwd',
|
|
313
|
+
createChatCompletion,
|
|
314
|
+
skillHomeDir: home,
|
|
315
|
+
})
|
|
316
|
+
const text = result.bubbles.join('\n')
|
|
317
|
+
const resolved = fs.realpathSync(workspace)
|
|
318
|
+
expect(text.includes(workspace) || text.includes(resolved)).toBe(true)
|
|
319
|
+
} finally {
|
|
320
|
+
fs.rmSync(workspace, { recursive: true, force: true })
|
|
321
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
322
|
+
}
|
|
323
|
+
})
|
|
324
|
+
|
|
235
325
|
it('attaches OpenChamber tools only and collects session cards from tool results', async () => {
|
|
236
326
|
const assignTool = {
|
|
237
327
|
name: 'assign_session',
|