@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.
Files changed (102) hide show
  1. package/dist/assets/{AgentAvatar-DI2zs2WH.js → AgentAvatar-CX0a1rKE.js} +1 -1
  2. package/dist/assets/AssistantView-9etSTPqw.js +2 -0
  3. package/dist/assets/{DiagramView-DRIEYUNV.js → DiagramView-koXYlpfQ.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-BWJcUp5P.js → FadeInOnReveal-CK4asqVR.js} +1 -1
  5. package/dist/assets/{Icon-4My8YZwt.js → Icon-BYM02QyU.js} +1 -1
  6. package/dist/assets/{MarkdownLoadingSkeleton-Vezjvk9B.js → MarkdownLoadingSkeleton-DWs4Jg7b.js} +1 -1
  7. package/dist/assets/{MarkdownRenderer-SLKCbxwI.js → MarkdownRenderer-BCVKHQSh.js} +2 -2
  8. package/dist/assets/{MarkdownRendererImpl-D9kcSswH.js → MarkdownRendererImpl-S3YOBsZc.js} +1 -1
  9. package/dist/assets/MarkstreamRendererImpl-DMJCTUAT.js +1 -0
  10. package/dist/assets/{MobileDetailNavigation-C5lZVW1v.js → MobileDetailNavigation-Bm5gj-LJ.js} +1 -1
  11. package/dist/assets/MobileShareBridge-BUSF-056.js +4 -0
  12. package/dist/assets/{MobileSurface-CtMp7rI8.js → MobileSurface-C95K6asy.js} +1 -1
  13. package/dist/assets/{MultiRunWindow-6-E944pd.js → MultiRunWindow-By8SN8e2.js} +1 -1
  14. package/dist/assets/{OnboardingScreen-BNEtPmvw.js → OnboardingScreen-C3-wzeSX.js} +1 -1
  15. package/dist/assets/{OverlayScrollbar-BZB21Yp_.js → OverlayScrollbar-KUpvYHdv.js} +1 -1
  16. package/dist/assets/{SettingsView-Cy_D8K8y.js → SettingsView-VL1AQnH4.js} +5 -5
  17. package/dist/assets/{SettingsWindow-BA9QjT8d.js → SettingsWindow-C54OTcdE.js} +1 -1
  18. package/dist/assets/{TerminalView-izdQm4WZ.js → TerminalView-DTUxTQA6.js} +1 -1
  19. package/dist/assets/{ToolOutputDialog-CXbfvZ3M.js → ToolOutputDialog-BdDYanZA.js} +1 -1
  20. package/dist/assets/{appThemeRegistry-Eq3WEH-h.js → appThemeRegistry-Bre7MHDa.js} +1 -1
  21. package/dist/assets/{button-Or0daT9o.js → button-C0cth9tl.js} +1 -1
  22. package/dist/assets/{clipboard-BXxLshVq.js → clipboard-DJ4nQEuY.js} +1 -1
  23. package/dist/assets/{collapsible-C-kzSGkq.js → collapsible-DFT9cE80.js} +1 -1
  24. package/dist/assets/{context-menu-Dpjla5p4.js → context-menu-CAp74NfV.js} +1 -1
  25. package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
  26. package/dist/assets/{desktopRelayRestore-BaKjk1kQ.js → desktopRelayRestore-9EbBYQ9s.js} +1 -1
  27. package/dist/assets/{diagram-D0mT3mo1.js → diagram-BryzMuta.js} +1 -1
  28. package/dist/assets/{dialog-BkhHBTd4.js → dialog-CgbV5ljg.js} +1 -1
  29. package/dist/assets/{fileQueries-DFY6Kiy1.js → fileQueries-BY-9OWzp.js} +1 -1
  30. package/dist/assets/{fileReferenceActions-Di_DoDex.js → fileReferenceActions-BJJtX_S5.js} +1 -1
  31. package/dist/assets/{gitApi-SIviXHpZ.js → gitApi-CUGT34gW.js} +1 -1
  32. package/dist/assets/{input-BUsGnzwe.js → input-DFLpqjGR.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-q72MeGLB.js → insertionBoundaries-BvRP83JP.js} +1 -1
  34. package/dist/assets/{main-Ct6J19qT.js → main-CFs9ZjGz.js} +2 -2
  35. package/dist/assets/{main-VXSSCHDb.js → main-Dkmkv4Lp.js} +11 -11
  36. package/dist/assets/{markdownTheme-CVS01wHf.js → markdownTheme-CzZm3riB.js} +1 -1
  37. package/dist/assets/{miniChat-D_SEWy1a.js → miniChat-C590b4rQ.js} +2 -2
  38. package/dist/assets/{mobile-Cx032aiN.js → mobile-CF2VLQTo.js} +2 -2
  39. package/dist/assets/{multirun-BKGQQeek.js → multirun-Cs3rEyOv.js} +1 -1
  40. package/dist/assets/{outsideFileGrants-BB4GPvCq.js → outsideFileGrants-DnplRxr-.js} +1 -1
  41. package/dist/assets/persistence-DdUac14I.js +3 -0
  42. package/dist/assets/platform-B4KKLnZ5.js +2 -0
  43. package/dist/assets/{projectMeta-yRCsEkJK.js → projectMeta-DaXQ-jtY.js} +1 -1
  44. package/dist/assets/{renderElectronMiniChatApp-BnDRaSFx.js → renderElectronMiniChatApp-DUdwGn52.js} +2 -2
  45. package/dist/assets/{renderMobileApp-BAGhZKMW.js → renderMobileApp-B0czsaKl.js} +7 -7
  46. package/dist/assets/{runtimeConfig-D5H05aTj.js → runtimeConfig-9_0HQLm2.js} +1 -1
  47. package/dist/assets/{runtimeEndpointReset-BwSh51Vc.js → runtimeEndpointReset-B6IvKLrw.js} +1 -1
  48. package/dist/assets/{sessionChangeSummary-CX2MJIkc.js → sessionChangeSummary-DOq8EN_H.js} +1 -1
  49. package/dist/assets/{sessionLookup-CSkeoKrV.js → sessionLookup-B2hi1Ihk.js} +1 -1
  50. package/dist/assets/{sortable-tabs-strip-C5gGnikj.js → sortable-tabs-strip-C8_9kHqu.js} +1 -1
  51. package/dist/assets/{terminalApi-CIIs9vFx.js → terminalApi-947l7Ra3.js} +1 -1
  52. package/dist/assets/{textarea-CIO34TZz.js → textarea-Cgo_nWUU.js} +1 -1
  53. package/dist/assets/{tooltip-BSrJYYYc.js → tooltip-BifLJ8o0.js} +1 -1
  54. package/dist/assets/{url-CE_gAfNa.js → url-Pk6tQ1EZ.js} +1 -1
  55. package/dist/assets/{useAppFontEffects-Dh_NwW5O.js → useAppFontEffects-0g5MBolQ.js} +6 -6
  56. package/dist/assets/{useAssistantUIStore-BHacrxe_.js → useAssistantUIStore-me6s63b2.js} +1 -1
  57. package/dist/assets/{useDesktopWindowControlsLayout-CV96SEmA.js → useDesktopWindowControlsLayout-DoKmTYzZ.js} +1 -1
  58. package/dist/assets/{useEffectiveDirectory-XSKpr7Fy.js → useEffectiveDirectory-CjEJa0jg.js} +1 -1
  59. package/dist/assets/{useFontPreferences-BxOHKuVw.js → useFontPreferences-BCOBlaAd.js} +1 -1
  60. package/dist/assets/useMobileNavigationStore-BflVzhUK.js +413 -0
  61. package/dist/assets/{useSessionAutoCleanup-Da5ILm3s.js → useSessionAutoCleanup-CUU4X2MO.js} +1 -1
  62. package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
  63. package/dist/assets/{useWorkerHighlightedLines-dC60TMnO.js → useWorkerHighlightedLines-BLg6m3sH.js} +1 -1
  64. package/dist/assets/{utils-B0mQXN9L.js → utils-BiK3tBGM.js} +1 -1
  65. package/dist/index.html +8 -8
  66. package/dist/mini-chat.html +8 -8
  67. package/dist/mobile.html +8 -8
  68. package/package.json +1 -1
  69. package/server/lib/assistants/DOCUMENTATION.md +3 -3
  70. package/server/lib/assistants/contact-tools.js +7 -4
  71. package/server/lib/assistants/contact-tools.test.js +18 -9
  72. package/server/lib/assistants/harness.js +117 -93
  73. package/server/lib/assistants/harness.test.js +93 -3
  74. package/server/lib/assistants/pi-tools.js +118 -0
  75. package/server/lib/assistants/pi-tools.test.js +114 -0
  76. package/server/lib/assistants/service.test.js +1 -1
  77. package/server/lib/event-stream/DOCUMENTATION.md +12 -1
  78. package/server/lib/event-stream/directory-ws-bridge.js +11 -2
  79. package/server/lib/event-stream/global-ws-bridge.js +19 -3
  80. package/server/lib/event-stream/protocol.js +11 -1
  81. package/server/lib/event-stream/reasoning-projection.js +425 -0
  82. package/server/lib/event-stream/reasoning-projection.test.js +300 -0
  83. package/server/lib/event-stream/runtime.js +10 -0
  84. package/server/lib/event-stream/runtime.test.js +53 -0
  85. package/server/lib/opencode/DOCUMENTATION.md +2 -1
  86. package/server/lib/opencode/proxy.js +103 -9
  87. package/server/lib/opencode/proxy.test.js +177 -1
  88. package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
  89. package/server/lib/session-turn-pages/DOCUMENTATION.md +2 -1
  90. package/server/lib/session-turn-pages/routes.js +22 -3
  91. package/server/lib/session-turn-pages/routes.test.js +43 -0
  92. package/server/lib/transcript-cache/DOCUMENTATION.md +2 -2
  93. package/server/lib/transcript-cache/routes.js +9 -2
  94. package/server/lib/transcript-cache/routes.test.js +32 -0
  95. package/dist/assets/AssistantView-BdZ8qKY7.js +0 -2
  96. package/dist/assets/MarkstreamRendererImpl-DnjoP9Et.js +0 -1
  97. package/dist/assets/MobileShareBridge-BeYYag96.js +0 -4
  98. package/dist/assets/desktopHosts-BzSE0sK7.js +0 -1
  99. package/dist/assets/persistence-CSZp1CNB.js +0 -3
  100. package/dist/assets/platform-CenlWqxu.js +0 -2
  101. package/dist/assets/useMobileNavigationStore-BrQwx0Jz.js +0 -412
  102. 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, not a coding agent.",
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
- 'Do not run bash, edit, read, or write.',
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 work: match the project, optionally list_sessions for existing chats, then assign_session with projectPath or sessionID.',
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 confirm = hasTool
422
- ? (extractToolResultText(slice) || extractAssistantText(slice))
423
- : extractAssistantText(slice);
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 + OpenChamber API
432
- * tools only + thinkingLevel off. Transcript in, completions via streamFn,
433
- * bubbles and session cards out. Never bash/edit/read/write.
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' && !['bash', 'edit', 'read', 'write'].includes(tool.name))
466
+ ? tools.filter((tool) => tool && typeof tool.name === 'string' && !isPiCodingToolName(tool.name))
455
467
  : [];
456
- const systemPrompt = [
457
- CONTACT_SYSTEM_PROMPT,
458
- formatRegisteredProjectsPrompt(projects),
459
- formatContactToolsPrompt(contactTools),
460
- assistant.defaultPrompt,
461
- ].filter((value) => typeof value === 'string' && value.trim()).join('\n\n');
462
- const model = createContactModel(providerID, modelID);
463
- // streamFn receives the model; completions needs providerID/modelID.
464
- model.name = `${providerID}/${modelID}`;
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
- const pendingFileParts = [
467
- ...(Array.isArray(history) ? history.flatMap((message) => completionFileParts(message.parts)) : []),
468
- ...completionFileParts(userParts),
469
- ];
470
- const prior = Array.isArray(history)
471
- ? history.map((message) => (
472
- message.role === 'assistant'
473
- ? { role: 'assistant', content: [{ type: 'text', text: message.content }], api: model.api, provider: model.provider, model: model.id, usage: emptyUsage(), stopReason: 'stop', timestamp: Date.now() }
474
- : {
475
- role: 'user',
476
- content: message.content,
477
- ...(completionFileParts(message.parts).length > 0 ? { parts: completionFileParts(message.parts) } : {}),
478
- timestamp: Date.now(),
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
- const agent = new AgentImpl({
484
- initialState: {
485
- systemPrompt,
486
- model,
487
- thinkingLevel: 'off',
488
- tools: contactTools,
489
- messages: prior,
490
- },
491
- streamFn: createContactStreamFn(createChatCompletion, {
492
- pendingFileParts,
493
- onTextDelta,
494
- onBubbleDelta,
495
- globalEventHub,
496
- bubbleGapMs: 280,
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
- await agent.prompt(userText);
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
- if (requested.length > 0 && !contactTurnHasToolResult(agent.state.messages)) {
518
- return {
519
- text: MISSED_TOOL_FAILURE_BUBBLE,
520
- bubbles: [MISSED_TOOL_FAILURE_BUBBLE],
521
- cards: [],
522
- thinkingLevel: agent.state.thinkingLevel,
523
- tools: [...agent.state.tools],
524
- };
525
- }
526
- const outcome = extractContactTurnOutcome(agent.state.messages, retried);
527
- const text = stripContactToolFences(outcome.text);
528
- if (contactTurnHasSuccessfulReset(agent.state.messages)) {
529
- const bubbles = confirmBubbleAfterContactReset(splitContactBubbles(text));
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: bubbles[0] || NEW_CONVERSATION_CONFIRM_BUBBLE,
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/openchamber-yee', label: 'OpenChamber Yee' }],
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/openchamber-yee')
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/openchamber-yee', label: 'OpenChamber Yee' }],
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, 'openchamber-yee');
1011
+ const project = path.join(directory, 'sample-app');
1012
1012
  fs.mkdirSync(project, { recursive: true });
1013
1013
  const creates = [];
1014
1014
  const harness = [];