@openchambery/web 1.19.7-beta.25 → 1.19.7-beta.27
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/{AgentAvatar-DI2zs2WH.js → AgentAvatar-CX0a1rKE.js} +1 -1
- package/dist/assets/AssistantView-9etSTPqw.js +2 -0
- package/dist/assets/{DiagramView-DRIEYUNV.js → DiagramView-koXYlpfQ.js} +1 -1
- package/dist/assets/{FadeInOnReveal-BWJcUp5P.js → FadeInOnReveal-CK4asqVR.js} +1 -1
- package/dist/assets/{Icon-4My8YZwt.js → Icon-BYM02QyU.js} +1 -1
- package/dist/assets/{MarkdownLoadingSkeleton-Vezjvk9B.js → MarkdownLoadingSkeleton-DWs4Jg7b.js} +1 -1
- package/dist/assets/{MarkdownRenderer-SLKCbxwI.js → MarkdownRenderer-BCVKHQSh.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-D9kcSswH.js → MarkdownRendererImpl-S3YOBsZc.js} +1 -1
- package/dist/assets/MarkstreamRendererImpl-DMJCTUAT.js +1 -0
- package/dist/assets/{MobileDetailNavigation-C5lZVW1v.js → MobileDetailNavigation-Bm5gj-LJ.js} +1 -1
- package/dist/assets/MobileShareBridge-BUSF-056.js +4 -0
- package/dist/assets/{MobileSurface-CtMp7rI8.js → MobileSurface-C95K6asy.js} +1 -1
- package/dist/assets/{MultiRunWindow-6-E944pd.js → MultiRunWindow-By8SN8e2.js} +1 -1
- package/dist/assets/{OnboardingScreen-BNEtPmvw.js → OnboardingScreen-C3-wzeSX.js} +1 -1
- package/dist/assets/{OverlayScrollbar-BZB21Yp_.js → OverlayScrollbar-KUpvYHdv.js} +1 -1
- package/dist/assets/{SettingsView-Cy_D8K8y.js → SettingsView-VL1AQnH4.js} +5 -5
- package/dist/assets/{SettingsWindow-BA9QjT8d.js → SettingsWindow-C54OTcdE.js} +1 -1
- package/dist/assets/{TerminalView-izdQm4WZ.js → TerminalView-DTUxTQA6.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CXbfvZ3M.js → ToolOutputDialog-BdDYanZA.js} +1 -1
- package/dist/assets/{appThemeRegistry-Eq3WEH-h.js → appThemeRegistry-Bre7MHDa.js} +1 -1
- package/dist/assets/{button-Or0daT9o.js → button-C0cth9tl.js} +1 -1
- package/dist/assets/{clipboard-BXxLshVq.js → clipboard-DJ4nQEuY.js} +1 -1
- package/dist/assets/{collapsible-C-kzSGkq.js → collapsible-DFT9cE80.js} +1 -1
- package/dist/assets/{context-menu-Dpjla5p4.js → context-menu-CAp74NfV.js} +1 -1
- package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
- package/dist/assets/{desktopRelayRestore-BaKjk1kQ.js → desktopRelayRestore-9EbBYQ9s.js} +1 -1
- package/dist/assets/{diagram-D0mT3mo1.js → diagram-BryzMuta.js} +1 -1
- package/dist/assets/{dialog-BkhHBTd4.js → dialog-CgbV5ljg.js} +1 -1
- package/dist/assets/{fileQueries-DFY6Kiy1.js → fileQueries-BY-9OWzp.js} +1 -1
- package/dist/assets/{fileReferenceActions-Di_DoDex.js → fileReferenceActions-BJJtX_S5.js} +1 -1
- package/dist/assets/{gitApi-SIviXHpZ.js → gitApi-CUGT34gW.js} +1 -1
- package/dist/assets/{input-BUsGnzwe.js → input-DFLpqjGR.js} +1 -1
- package/dist/assets/{insertionBoundaries-q72MeGLB.js → insertionBoundaries-BvRP83JP.js} +1 -1
- package/dist/assets/{main-Ct6J19qT.js → main-CFs9ZjGz.js} +2 -2
- package/dist/assets/{main-VXSSCHDb.js → main-Dkmkv4Lp.js} +11 -11
- package/dist/assets/{markdownTheme-CVS01wHf.js → markdownTheme-CzZm3riB.js} +1 -1
- package/dist/assets/{miniChat-D_SEWy1a.js → miniChat-C590b4rQ.js} +2 -2
- package/dist/assets/{mobile-Cx032aiN.js → mobile-CF2VLQTo.js} +2 -2
- package/dist/assets/{multirun-BKGQQeek.js → multirun-Cs3rEyOv.js} +1 -1
- package/dist/assets/{outsideFileGrants-BB4GPvCq.js → outsideFileGrants-DnplRxr-.js} +1 -1
- package/dist/assets/persistence-DdUac14I.js +3 -0
- package/dist/assets/platform-B4KKLnZ5.js +2 -0
- package/dist/assets/{projectMeta-yRCsEkJK.js → projectMeta-DaXQ-jtY.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BnDRaSFx.js → renderElectronMiniChatApp-DUdwGn52.js} +2 -2
- package/dist/assets/{renderMobileApp-BAGhZKMW.js → renderMobileApp-B0czsaKl.js} +7 -7
- package/dist/assets/{runtimeConfig-D5H05aTj.js → runtimeConfig-9_0HQLm2.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-BwSh51Vc.js → runtimeEndpointReset-B6IvKLrw.js} +1 -1
- package/dist/assets/{sessionChangeSummary-CX2MJIkc.js → sessionChangeSummary-DOq8EN_H.js} +1 -1
- package/dist/assets/{sessionLookup-CSkeoKrV.js → sessionLookup-B2hi1Ihk.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-C5gGnikj.js → sortable-tabs-strip-C8_9kHqu.js} +1 -1
- package/dist/assets/{terminalApi-CIIs9vFx.js → terminalApi-947l7Ra3.js} +1 -1
- package/dist/assets/{textarea-CIO34TZz.js → textarea-Cgo_nWUU.js} +1 -1
- package/dist/assets/{tooltip-BSrJYYYc.js → tooltip-BifLJ8o0.js} +1 -1
- package/dist/assets/{url-CE_gAfNa.js → url-Pk6tQ1EZ.js} +1 -1
- package/dist/assets/{useAppFontEffects-Dh_NwW5O.js → useAppFontEffects-0g5MBolQ.js} +6 -6
- package/dist/assets/{useAssistantUIStore-BHacrxe_.js → useAssistantUIStore-me6s63b2.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-CV96SEmA.js → useDesktopWindowControlsLayout-DoKmTYzZ.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-XSKpr7Fy.js → useEffectiveDirectory-CjEJa0jg.js} +1 -1
- package/dist/assets/{useFontPreferences-BxOHKuVw.js → useFontPreferences-BCOBlaAd.js} +1 -1
- package/dist/assets/useMobileNavigationStore-BflVzhUK.js +413 -0
- package/dist/assets/{useSessionAutoCleanup-Da5ILm3s.js → useSessionAutoCleanup-CUU4X2MO.js} +1 -1
- package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-dC60TMnO.js → useWorkerHighlightedLines-BLg6m3sH.js} +1 -1
- package/dist/assets/{utils-B0mQXN9L.js → utils-BiK3tBGM.js} +1 -1
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- 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 +18 -9
- package/server/lib/assistants/harness.js +117 -93
- package/server/lib/assistants/harness.test.js +93 -3
- package/server/lib/assistants/pi-tools.js +118 -0
- package/server/lib/assistants/pi-tools.test.js +114 -0
- package/server/lib/assistants/service.test.js +1 -1
- package/server/lib/event-stream/DOCUMENTATION.md +12 -1
- package/server/lib/event-stream/directory-ws-bridge.js +11 -2
- package/server/lib/event-stream/global-ws-bridge.js +19 -3
- package/server/lib/event-stream/protocol.js +11 -1
- package/server/lib/event-stream/reasoning-projection.js +425 -0
- package/server/lib/event-stream/reasoning-projection.test.js +300 -0
- package/server/lib/event-stream/runtime.js +10 -0
- package/server/lib/event-stream/runtime.test.js +53 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/proxy.js +103 -9
- package/server/lib/opencode/proxy.test.js +177 -1
- package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
- package/server/lib/session-turn-pages/routes.js +22 -3
- package/server/lib/session-turn-pages/routes.test.js +43 -0
- package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
- package/server/lib/transcript-cache/routes.js +9 -2
- package/server/lib/transcript-cache/routes.test.js +32 -0
- package/dist/assets/AssistantView-BdZ8qKY7.js +0 -2
- package/dist/assets/MarkstreamRendererImpl-DnjoP9Et.js +0 -1
- package/dist/assets/MobileShareBridge-BeYYag96.js +0 -4
- package/dist/assets/desktopHosts-BzSE0sK7.js +0 -1
- package/dist/assets/persistence-CSZp1CNB.js +0 -3
- package/dist/assets/platform-CenlWqxu.js +0 -2
- package/dist/assets/useMobileNavigationStore-BrQwx0Jz.js +0 -412
- package/dist/assets/useUIStore-CqqO11ZI.js +0 -1
|
@@ -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',
|
|
@@ -285,7 +375,7 @@ describe('runContactTurn', () => {
|
|
|
285
375
|
userText: 'assign login',
|
|
286
376
|
createChatCompletion: vi.fn(),
|
|
287
377
|
tools: [assignTool, { name: 'bash', execute: vi.fn() }],
|
|
288
|
-
projects: [{ id: 'proj_yee', path: '/repo/
|
|
378
|
+
projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
|
|
289
379
|
AgentImpl,
|
|
290
380
|
})
|
|
291
381
|
expect(result.bubbles).toEqual(['opened'])
|
|
@@ -382,7 +472,7 @@ describe('runContactTurn', () => {
|
|
|
382
472
|
function AgentImpl(options) {
|
|
383
473
|
expect(options.initialState.systemPrompt).toContain('Registered projects')
|
|
384
474
|
expect(options.initialState.systemPrompt).toContain('OpenChamber Yee')
|
|
385
|
-
expect(options.initialState.systemPrompt).toContain('/repo/
|
|
475
|
+
expect(options.initialState.systemPrompt).toContain('/repo/sample-app')
|
|
386
476
|
expect(options.initialState.systemPrompt).toContain('never say you cannot see registered projects')
|
|
387
477
|
this.state = { ...options.initialState, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'Found it.' }] }] }
|
|
388
478
|
this.prompt = async () => {}
|
|
@@ -392,7 +482,7 @@ describe('runContactTurn', () => {
|
|
|
392
482
|
history: [],
|
|
393
483
|
userText: '找 openchamber yee',
|
|
394
484
|
createChatCompletion: vi.fn(),
|
|
395
|
-
projects: [{ id: 'proj_yee', path: '/repo/
|
|
485
|
+
projects: [{ id: 'proj_yee', path: '/repo/sample-app', label: 'OpenChamber Yee' }],
|
|
396
486
|
AgentImpl,
|
|
397
487
|
})
|
|
398
488
|
expect(result.bubbles).toEqual(['Found it.'])
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
createBashTool,
|
|
5
|
+
createEditTool,
|
|
6
|
+
createReadTool,
|
|
7
|
+
createWriteTool,
|
|
8
|
+
formatSkillsForSystemPrompt,
|
|
9
|
+
loadSkills,
|
|
10
|
+
} from '@earendil-works/pi-agent-core';
|
|
11
|
+
import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node';
|
|
12
|
+
|
|
13
|
+
const EXTERNAL_SKILL_ROOTS = Object.freeze(['.claude', '.agents']);
|
|
14
|
+
|
|
15
|
+
export const PI_CODING_TOOL_NAMES = Object.freeze(['read', 'write', 'edit', 'bash']);
|
|
16
|
+
const PI_CODING_TOOL_NAME_SET = new Set(PI_CODING_TOOL_NAMES);
|
|
17
|
+
|
|
18
|
+
export function isPiCodingToolName(name) {
|
|
19
|
+
return typeof name === 'string' && PI_CODING_TOOL_NAME_SET.has(name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveAssistantCwd(assistant) {
|
|
23
|
+
for (const value of [assistant?.effectiveWorkspacePath, assistant?.workspacePath]) {
|
|
24
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Global ~/.claude/skills + ~/.agents/skills, then this project cwd (.claude then .agents). Later dirs win on name. */
|
|
30
|
+
export function collectAssistantSkillDirectories(cwd, { homeDir = os.homedir() } = {}) {
|
|
31
|
+
const dirs = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
const push = (dir) => {
|
|
34
|
+
if (typeof dir !== 'string' || !dir.trim()) return;
|
|
35
|
+
const resolved = path.resolve(dir);
|
|
36
|
+
if (seen.has(resolved)) return;
|
|
37
|
+
seen.add(resolved);
|
|
38
|
+
dirs.push(resolved);
|
|
39
|
+
};
|
|
40
|
+
const home = typeof homeDir === 'string' && homeDir.trim() ? path.resolve(homeDir.trim()) : os.homedir();
|
|
41
|
+
for (const root of EXTERNAL_SKILL_ROOTS) {
|
|
42
|
+
push(path.join(home, root, 'skills'));
|
|
43
|
+
}
|
|
44
|
+
if (typeof cwd === 'string' && cwd.trim()) {
|
|
45
|
+
const project = path.resolve(cwd.trim());
|
|
46
|
+
for (const root of EXTERNAL_SKILL_ROOTS) {
|
|
47
|
+
push(path.join(project, root, 'skills'));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return dirs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mergeSkillsByName(skills) {
|
|
54
|
+
const map = new Map();
|
|
55
|
+
for (const skill of Array.isArray(skills) ? skills : []) {
|
|
56
|
+
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
|
|
57
|
+
if (!name) continue;
|
|
58
|
+
map.set(name, skill);
|
|
59
|
+
}
|
|
60
|
+
return [...map.values()];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const bindHarnessTool = (tool, context) => ({
|
|
64
|
+
name: tool.name,
|
|
65
|
+
label: tool.label,
|
|
66
|
+
description: tool.description,
|
|
67
|
+
parameters: tool.parameters,
|
|
68
|
+
execute: (toolCallId, params, signal, onUpdate) => (
|
|
69
|
+
tool.execute(toolCallId, params, signal, onUpdate, context)
|
|
70
|
+
),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export function formatPiCodingPrompt({ cwd, skillsPrompt = '' } = {}) {
|
|
74
|
+
if (typeof cwd !== 'string' || !cwd.trim()) return '';
|
|
75
|
+
return [
|
|
76
|
+
`Working directory: ${cwd}`,
|
|
77
|
+
'You have pi coding tools in this directory: read, write, edit, bash.',
|
|
78
|
+
'This is your working directory. Ignore any other cwd from the environment, including temporary generator workspaces under /var/folders or os.tmpdir.',
|
|
79
|
+
'When the user asks pwd, the current directory, or to look at files, call bash/read yourself. Never say you have no terminal or cannot read or write files.',
|
|
80
|
+
'Skills are merged from ~/.claude/skills, ~/.agents/skills, and this project\'s .claude/skills plus .agents/skills (project wins on name). Read a skill file when the task matches its description.',
|
|
81
|
+
'Call these tools with the same fenced JSON as other tools:',
|
|
82
|
+
'```openchamber-tool',
|
|
83
|
+
'{"name":"bash","arguments":{"command":"pwd"}}',
|
|
84
|
+
'```',
|
|
85
|
+
'```openchamber-tool',
|
|
86
|
+
'{"name":"read","arguments":{"path":".agents/skills/example/SKILL.md"}}',
|
|
87
|
+
'```',
|
|
88
|
+
typeof skillsPrompt === 'string' && skillsPrompt.trim() ? skillsPrompt.trim() : '',
|
|
89
|
+
].filter(Boolean).join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function createPiCodingRuntime(cwd, { homeDir = os.homedir() } = {}) {
|
|
93
|
+
if (typeof cwd !== 'string' || !cwd.trim()) return null;
|
|
94
|
+
const resolved = cwd.trim();
|
|
95
|
+
const env = new NodeExecutionEnv({ cwd: resolved });
|
|
96
|
+
const context = { env };
|
|
97
|
+
const tools = [
|
|
98
|
+
bindHarnessTool(createReadTool(), context),
|
|
99
|
+
bindHarnessTool(createWriteTool(), context),
|
|
100
|
+
bindHarnessTool(createEditTool(), context),
|
|
101
|
+
bindHarnessTool(createBashTool(), context),
|
|
102
|
+
];
|
|
103
|
+
let skillsPrompt = '';
|
|
104
|
+
try {
|
|
105
|
+
const loaded = await loadSkills(env, collectAssistantSkillDirectories(resolved, { homeDir }));
|
|
106
|
+
skillsPrompt = formatSkillsForSystemPrompt(mergeSkillsByName(loaded?.skills));
|
|
107
|
+
} catch {
|
|
108
|
+
skillsPrompt = '';
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
cwd: resolved,
|
|
112
|
+
tools,
|
|
113
|
+
skillsPrompt,
|
|
114
|
+
close: async () => {
|
|
115
|
+
await env.cleanup();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
collectAssistantSkillDirectories,
|
|
7
|
+
createPiCodingRuntime,
|
|
8
|
+
mergeSkillsByName,
|
|
9
|
+
PI_CODING_TOOL_NAMES,
|
|
10
|
+
resolveAssistantCwd,
|
|
11
|
+
} from './pi-tools.js';
|
|
12
|
+
|
|
13
|
+
const temps = [];
|
|
14
|
+
const temp = () => {
|
|
15
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-pi-tools-'));
|
|
16
|
+
temps.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
};
|
|
19
|
+
const writeSkill = (root, name, description = `${name} skill`) => {
|
|
20
|
+
const dir = path.join(root, name);
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\nInstructions for ${name}\n`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
while (temps.length > 0) {
|
|
27
|
+
fs.rmSync(temps.pop(), { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('resolveAssistantCwd', () => {
|
|
32
|
+
it('prefers effectiveWorkspacePath over workspacePath', () => {
|
|
33
|
+
expect(resolveAssistantCwd({
|
|
34
|
+
effectiveWorkspacePath: '/effective',
|
|
35
|
+
workspacePath: '/workspace',
|
|
36
|
+
})).toBe('/effective');
|
|
37
|
+
expect(resolveAssistantCwd({ workspacePath: '/workspace' })).toBe('/workspace');
|
|
38
|
+
expect(resolveAssistantCwd({ defaultPrompt: 'x' })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('collectAssistantSkillDirectories', () => {
|
|
43
|
+
it('lists global ~/.claude and ~/.agents then project .claude and .agents', () => {
|
|
44
|
+
const home = temp();
|
|
45
|
+
const cwd = temp();
|
|
46
|
+
expect(collectAssistantSkillDirectories(cwd, { homeDir: home })).toEqual([
|
|
47
|
+
path.resolve(home, '.claude', 'skills'),
|
|
48
|
+
path.resolve(home, '.agents', 'skills'),
|
|
49
|
+
path.resolve(cwd, '.claude', 'skills'),
|
|
50
|
+
path.resolve(cwd, '.agents', 'skills'),
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not invent skill roots on intermediate parent folders', () => {
|
|
55
|
+
const home = temp();
|
|
56
|
+
const root = temp();
|
|
57
|
+
const nested = path.join(root, 'apps', 'web');
|
|
58
|
+
fs.mkdirSync(nested, { recursive: true });
|
|
59
|
+
expect(collectAssistantSkillDirectories(nested, { homeDir: home })).toEqual([
|
|
60
|
+
path.resolve(home, '.claude', 'skills'),
|
|
61
|
+
path.resolve(home, '.agents', 'skills'),
|
|
62
|
+
path.resolve(nested, '.claude', 'skills'),
|
|
63
|
+
path.resolve(nested, '.agents', 'skills'),
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('mergeSkillsByName', () => {
|
|
69
|
+
it('keeps the last skill with the same name', () => {
|
|
70
|
+
expect(mergeSkillsByName([
|
|
71
|
+
{ name: 'shared', filePath: '/global' },
|
|
72
|
+
{ name: 'shared', filePath: '/project' },
|
|
73
|
+
{ name: 'other', filePath: '/other' },
|
|
74
|
+
])).toEqual([
|
|
75
|
+
{ name: 'shared', filePath: '/project' },
|
|
76
|
+
{ name: 'other', filePath: '/other' },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('createPiCodingRuntime', () => {
|
|
82
|
+
it('installs read/write/edit/bash and merges global plus project skills', async () => {
|
|
83
|
+
const home = temp();
|
|
84
|
+
const cwd = temp();
|
|
85
|
+
writeSkill(path.join(home, '.claude', 'skills'), 'claude-global', 'Claude global');
|
|
86
|
+
writeSkill(path.join(home, '.agents', 'skills'), 'agents-global', 'Agents global');
|
|
87
|
+
writeSkill(path.join(cwd, '.claude', 'skills'), 'claude-project', 'Claude project');
|
|
88
|
+
writeSkill(path.join(cwd, '.agents', 'skills'), 'project-skill', 'Project skill');
|
|
89
|
+
writeSkill(path.join(home, '.agents', 'skills'), 'shared', 'Global shared');
|
|
90
|
+
writeSkill(path.join(cwd, '.agents', 'skills'), 'shared', 'Project shared');
|
|
91
|
+
|
|
92
|
+
const runtime = await createPiCodingRuntime(cwd, { homeDir: home });
|
|
93
|
+
expect(runtime.tools.map((tool) => tool.name)).toEqual([...PI_CODING_TOOL_NAMES]);
|
|
94
|
+
expect(runtime.skillsPrompt).toContain('claude-global');
|
|
95
|
+
expect(runtime.skillsPrompt).toContain('agents-global');
|
|
96
|
+
expect(runtime.skillsPrompt).toContain('claude-project');
|
|
97
|
+
expect(runtime.skillsPrompt).toContain('project-skill');
|
|
98
|
+
expect(runtime.skillsPrompt).toContain('Project shared');
|
|
99
|
+
expect(runtime.skillsPrompt).not.toContain('Global shared');
|
|
100
|
+
await runtime.close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('runs bash pwd in the workspace', async () => {
|
|
104
|
+
const cwd = temp();
|
|
105
|
+
const home = temp();
|
|
106
|
+
const runtime = await createPiCodingRuntime(cwd, { homeDir: home });
|
|
107
|
+
const bash = runtime.tools.find((tool) => tool.name === 'bash');
|
|
108
|
+
const result = await bash.execute('call_pwd', { command: 'pwd' });
|
|
109
|
+
const text = result.content.map((part) => part.text).join('');
|
|
110
|
+
const resolved = fs.realpathSync(cwd);
|
|
111
|
+
expect(text.includes(cwd) || text.includes(resolved)).toBe(true);
|
|
112
|
+
await runtime.close();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -1008,7 +1008,7 @@ describe('assistants service', () => {
|
|
|
1008
1008
|
|
|
1009
1009
|
it('injects registered projects into the contact turn and assigns after a name match', async () => {
|
|
1010
1010
|
const directory = root();
|
|
1011
|
-
const project = path.join(directory, '
|
|
1011
|
+
const project = path.join(directory, 'sample-app');
|
|
1012
1012
|
fs.mkdirSync(project, { recursive: true });
|
|
1013
1013
|
const creates = [];
|
|
1014
1014
|
const harness = [];
|