@openchambery/web 1.19.7-beta.9 → 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/AgentAvatar-CX0a1rKE.js +1 -0
- package/dist/assets/AssistantView-DkXW9Fzq.js +2 -0
- package/dist/assets/DiagramView-C8MZY0fA.js +1 -0
- package/dist/assets/FadeInOnReveal-CK4asqVR.js +1 -0
- package/dist/assets/Icon-BYM02QyU.js +1 -0
- package/dist/assets/MarkdownLoadingSkeleton-DWs4Jg7b.js +3 -0
- package/dist/assets/MarkdownRenderer-cxYw8-8G.js +2 -0
- package/dist/assets/MarkdownRendererImpl-B39nwiZX.js +7 -0
- package/dist/assets/MarkstreamRendererImpl-CdYqdejg.js +1 -0
- package/dist/assets/MarkstreamRendererImpl-CsNwy1T5.css +1 -0
- package/dist/assets/MobileDetailNavigation-D6ri-ePj.js +1 -0
- package/dist/assets/MobileShareBridge-HRTN71wg.js +4 -0
- package/dist/assets/MobileSurface-D2yHjnZw.js +2 -0
- package/dist/assets/{MultiRunWindow-Ohj6UbWj.js → MultiRunWindow-CTRgfYQD.js} +1 -1
- package/dist/assets/OnboardingScreen-C3-wzeSX.js +1 -0
- package/dist/assets/OverlayScrollbar-KUpvYHdv.js +2 -0
- package/dist/assets/SettingsView-ImI02YSp.js +25 -0
- package/dist/assets/{SettingsWindow-B4lzhAbO.js → SettingsWindow-DWo2lUaN.js} +1 -1
- package/dist/assets/TerminalView-BseZ5dR-.js +8 -0
- package/dist/assets/ToolOutputDialog-BIK3cPqo.js +10 -0
- package/dist/assets/appThemeRegistry-4_bwYAWt.js +1 -0
- package/dist/assets/{button-z0GOBtWi.js → button-C0cth9tl.js} +1 -1
- package/dist/assets/clipboard-DJ4nQEuY.js +1 -0
- package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-DFT9cE80.js} +1 -1
- package/dist/assets/context-menu-CAp74NfV.js +1 -0
- package/dist/assets/desktopHosts-CWxex1c3.js +1 -0
- package/dist/assets/desktopRelayRestore-9EbBYQ9s.js +1 -0
- package/dist/assets/{diagram-CoEqmReY.js → diagram-BryzMuta.js} +1 -1
- package/dist/assets/{dialog-x2hirWBd.js → dialog-CgbV5ljg.js} +1 -1
- package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
- package/dist/assets/{es-C8Sqjsia.js → es-BHqnSjoZ.js} +1 -1
- package/dist/assets/fileQueries-BY-9OWzp.js +1 -0
- package/dist/assets/fileReferenceActions-BJJtX_S5.js +1 -0
- package/dist/assets/{fr-p-Zwm_j2.js → fr-BfrlJY9i.js} +1 -1
- package/dist/assets/{gitApi-CtS4kWoo.js → gitApi-m09MWmzE.js} +3 -3
- package/dist/assets/{input-BG3VWTIF.js → input-DFLpqjGR.js} +1 -1
- package/dist/assets/insertionBoundaries-B0fzEL22.js +4 -0
- package/dist/assets/{ja-B6REu1qP.js → ja-DP-7qt-x.js} +1 -1
- package/dist/assets/{ko-CJBNH9IZ.js → ko-E2B5vGQP.js} +1 -1
- package/dist/assets/main-BYX7_XVg.js +169 -0
- package/dist/assets/main-BpbNOMNf.js +2 -0
- package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
- package/dist/assets/markdownTheme-CzZm3riB.js +2 -0
- package/dist/assets/miniChat-CFBgCVoZ.js +2 -0
- package/dist/assets/mobile-DcZkxRBz.js +2 -0
- package/dist/assets/multirun-CBqJQgS2.js +4 -0
- package/dist/assets/outsideFileGrants-DnplRxr-.js +1 -0
- package/dist/assets/path-utils-jGozTg9d.js +1 -0
- package/dist/assets/persistence-DdUac14I.js +3 -0
- package/dist/assets/{pl-B2cBI_7K.js → pl-r8X5gRM5.js} +1 -1
- package/dist/assets/platform-B4KKLnZ5.js +2 -0
- package/dist/assets/projectMeta-Ct1qHZhU.js +3 -0
- package/dist/assets/{pt-BR-CrCc9hmt.js → pt-BR-DcpdXCf5.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-DxmxYdiE.js +2 -0
- package/dist/assets/renderMobileApp-XXw7Fnrr.js +28 -0
- package/dist/assets/runtimeConfig-CcIqxMFB.js +2 -0
- package/dist/assets/runtimeEndpointReset-CU1HhJfC.js +147 -0
- package/dist/assets/sessionChangeSummary-DOq8EN_H.js +1 -0
- package/dist/assets/sessionLookup-CNMNR-yh.js +1 -0
- package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +1 -0
- package/dist/assets/src-DasvfkEg.css +2 -0
- package/dist/assets/{store-CL3WP48T.js → store-BDFdnk6l.js} +2 -2
- package/dist/assets/terminalApi-947l7Ra3.js +1 -0
- package/dist/assets/textarea-Cgo_nWUU.js +1 -0
- package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
- package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BifLJ8o0.js} +1 -1
- package/dist/assets/{uk-C8YKAz0x.js → uk-Di0vl9V-.js} +1 -1
- package/dist/assets/url-Pk6tQ1EZ.js +1 -0
- package/dist/assets/{ChatContainer-CDExXwgn.js → useAppFontEffects-CBLEH9it.js} +44 -39
- package/dist/assets/useAssistantUIStore-me6s63b2.js +1 -0
- package/dist/assets/useDesktopWindowControlsLayout-DMCmF610.js +1 -0
- package/dist/assets/useEffectiveDirectory-CMvw1bLA.js +1 -0
- package/dist/assets/useFontPreferences-BCOBlaAd.js +1 -0
- package/dist/assets/useMobileNavigationStore-Di9IWKfu.js +413 -0
- package/dist/assets/useSessionAutoCleanup-DMyMP_xI.js +1 -0
- package/dist/assets/useUIStore-CuaA8-Hp.js +1 -0
- package/dist/assets/useWorkerHighlightedLines-uNzY5rmU.js +7 -0
- package/dist/assets/utils-BiK3tBGM.js +1 -0
- package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
- package/dist/assets/{zh-CN-CCYwUnmB.js → zh-CN-coFCfeQE.js} +1 -1
- package/dist/assets/{zh-TW-D8Y6py08.js → zh-TW-oDRZ1-yb.js} +1 -1
- package/dist/index.html +9 -11
- package/dist/mini-chat.html +9 -11
- package/dist/mobile.html +15 -17
- package/package.json +2 -1
- package/server/index.js +2 -0
- package/server/lib/assistant-delivery-parts.js +3 -1
- package/server/lib/assistant-delivery-parts.test.js +11 -0
- package/server/lib/assistants/DOCUMENTATION.md +77 -4
- package/server/lib/assistants/assign.js +225 -0
- package/server/lib/assistants/assign.test.js +118 -0
- package/server/lib/assistants/bubbles.js +45 -0
- package/server/lib/assistants/bubbles.test.js +36 -0
- package/server/lib/assistants/cards.js +148 -0
- package/server/lib/assistants/cards.test.js +116 -0
- package/server/lib/assistants/contact-store.js +284 -0
- package/server/lib/assistants/contact-store.test.js +131 -0
- package/server/lib/assistants/contact-tools.js +764 -0
- package/server/lib/assistants/contact-tools.test.js +337 -0
- package/server/lib/assistants/contracts.js +20 -1
- package/server/lib/assistants/harness.js +575 -0
- package/server/lib/assistants/harness.test.js +697 -0
- package/server/lib/assistants/pi-tools.js +118 -0
- package/server/lib/assistants/pi-tools.test.js +114 -0
- package/server/lib/assistants/routes.js +44 -4
- package/server/lib/assistants/service.js +930 -17
- package/server/lib/assistants/service.test.js +1163 -45
- 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/llm/DOCUMENTATION.md +84 -0
- package/server/lib/llm/catalog.js +100 -0
- package/server/lib/llm/catalog.test.js +78 -0
- package/server/lib/llm/completions.js +151 -0
- package/server/lib/llm/completions.test.js +127 -0
- package/server/lib/llm/generate.js +570 -0
- package/server/lib/llm/generate.test.js +459 -0
- package/server/lib/llm/routes.js +47 -0
- package/server/lib/llm/temp-directory.js +37 -0
- package/server/lib/notifications/APNS.md +19 -13
- package/server/lib/notifications/DOCUMENTATION.md +4 -3
- package/server/lib/notifications/runtime.js +148 -37
- package/server/lib/notifications/runtime.test.js +100 -0
- package/server/lib/opencode/DOCUMENTATION.md +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +30 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
- package/server/lib/opencode/proxy.js +103 -9
- package/server/lib/opencode/proxy.test.js +177 -1
- package/server/lib/opencode/settings-runtime.js +2 -2
- package/server/lib/relay/reasoning-projection.e2e.test.ts +529 -0
- package/server/lib/session-goal/DOCUMENTATION.md +8 -1
- package/server/lib/session-goal/runtime.js +20 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -1
- package/server/lib/session-index/service.js +10 -4
- package/server/lib/session-index/service.test.js +37 -0
- package/server/lib/session-title/runtime.js +1 -0
- package/server/lib/session-title/runtime.test.js +11 -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/AgentAvatar-Di8F4QJh.js +0 -1
- package/dist/assets/AssistantView-DSxVAAb_.js +0 -1
- package/dist/assets/ContextPanel-DgwEIdqR.js +0 -285
- package/dist/assets/DiagramView-BKQO61y8.js +0 -1
- package/dist/assets/FadeInOnReveal-CV2s6YN8.js +0 -1
- package/dist/assets/Icon-CLpn1mIz.js +0 -1
- package/dist/assets/MarkdownRendererImpl-CTgmghSh.js +0 -7
- package/dist/assets/MarkstreamRendererImpl-BKsc-avF.js +0 -1
- package/dist/assets/MarkstreamRendererImpl-CxeWpDCK.css +0 -1
- package/dist/assets/MobileDetailNavigation-BcA6rd8Q.js +0 -1
- package/dist/assets/MobileShareBridge-w6T2ZrsW.js +0 -4
- package/dist/assets/MobileSurface-CG1n7v0h.js +0 -2
- package/dist/assets/OnboardingScreen-D6rtE7rj.js +0 -1
- package/dist/assets/OverlayScrollbar-DlNZho3q.js +0 -2
- package/dist/assets/SettingsView-CiF3r83e.js +0 -25
- package/dist/assets/TerminalView-Ctx_Jt_b.js +0 -8
- package/dist/assets/ToolOutputDialog-Dv9_fUYl.js +0 -10
- package/dist/assets/appThemeRegistry-DnwUIQB9.js +0 -2
- package/dist/assets/clipboard-D35XSwID.js +0 -1
- package/dist/assets/desktop-CfLlmafK.js +0 -1
- package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
- package/dist/assets/desktopRelayRestore-8pvI_dSc.js +0 -1
- package/dist/assets/fileQueries-DGHZqZHc.js +0 -1
- package/dist/assets/insertionBoundaries-hK4mQj4W.js +0 -4
- package/dist/assets/main-BmbZFoO4.js +0 -29
- package/dist/assets/main-BwNhn8ml.js +0 -2
- package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
- package/dist/assets/markdownTheme-SPe4jOwX.js +0 -2
- package/dist/assets/miniChat-BN89siC3.js +0 -2
- package/dist/assets/mobile-DE9rVo5D.js +0 -2
- package/dist/assets/multirun-BiEuyDq3.js +0 -4
- package/dist/assets/persistence-w-AiX2i4.js +0 -3
- package/dist/assets/platform-4eCmsdol.js +0 -2
- package/dist/assets/projectMeta-DSLKcH1a.js +0 -3
- package/dist/assets/radio-HD5AB2Qf.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-f_UxpFjv.js +0 -2
- package/dist/assets/renderMobileApp-BAhCai6d.js +0 -28
- package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
- package/dist/assets/runtimeConfig-qG_oNBoa.js +0 -2
- package/dist/assets/runtimeEndpointReset-BDeKD3EM.js +0 -4
- package/dist/assets/shikiHighlight-BphLZnac.js +0 -1
- package/dist/assets/sortable-tabs-strip-CNDgyX0B.js +0 -1
- package/dist/assets/src-DZt1nWf5.css +0 -2
- package/dist/assets/terminalApi-CGtOtc8I.js +0 -1
- package/dist/assets/url-KeihbE0d.js +0 -1
- package/dist/assets/useAppFontEffects--0dwVTSm.js +0 -7
- package/dist/assets/useAssistantUIStore-I0VWrB5V.js +0 -1
- package/dist/assets/useDesktopWindowControlsLayout-DsbeBLvv.js +0 -1
- package/dist/assets/useEffectiveDirectory-CHvvohcG.js +0 -1
- package/dist/assets/useFontPreferences-BLZ3y6mN.js +0 -1
- package/dist/assets/useMobileNavigationStore-peTDuC00.js +0 -412
- package/dist/assets/useSessionAutoCleanup-D_ZC9E5g.js +0 -1
- package/dist/assets/useUIStore-CNLmCoPU.js +0 -1
- package/dist/assets/useWorkerHighlightedLines-B1YzuMYR.js +0 -7
- package/dist/assets/utils-CTL16t2f.js +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
ASSIGN_CODES,
|
|
7
|
+
PROJECT_REQUIRED_MESSAGE,
|
|
8
|
+
assignSession,
|
|
9
|
+
isManagedAssistantWorkspace,
|
|
10
|
+
resolveAssignDirectory,
|
|
11
|
+
} from './assign.js';
|
|
12
|
+
|
|
13
|
+
const root = () => fs.mkdtempSync(path.join(os.tmpdir(), 'assign-'));
|
|
14
|
+
|
|
15
|
+
describe('resolveAssignDirectory', () => {
|
|
16
|
+
it('requires a registered project root', () => {
|
|
17
|
+
expect(() => resolveAssignDirectory({ allowedRoots: [] })).toThrowError(
|
|
18
|
+
expect.objectContaining({ code: ASSIGN_CODES.PROJECT_REQUIRED, message: PROJECT_REQUIRED_MESSAGE }),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects managed assistant-workspaces even when they sit under an allowed root', () => {
|
|
23
|
+
const directory = root();
|
|
24
|
+
const managed = path.join(directory, 'assistant-workspaces', 'asst_1');
|
|
25
|
+
fs.mkdirSync(managed, { recursive: true });
|
|
26
|
+
expect(isManagedAssistantWorkspace(managed, path.join(directory, 'assistant-workspaces'))).toBe(true);
|
|
27
|
+
expect(() => resolveAssignDirectory({
|
|
28
|
+
directory: managed,
|
|
29
|
+
allowedRoots: [directory],
|
|
30
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
31
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.WORKSPACE_FORBIDDEN }));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('resolves an existing worktree for a branch and refuses a missing one', () => {
|
|
35
|
+
const directory = root();
|
|
36
|
+
const project = path.join(directory, 'project');
|
|
37
|
+
const worktree = path.join(directory, 'worktrees', 'login');
|
|
38
|
+
fs.mkdirSync(project, { recursive: true });
|
|
39
|
+
fs.mkdirSync(worktree, { recursive: true });
|
|
40
|
+
expect(resolveAssignDirectory({
|
|
41
|
+
projectPath: project,
|
|
42
|
+
branch: 'feat-login',
|
|
43
|
+
allowedRoots: [directory],
|
|
44
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
45
|
+
worktrees: [{ branch: 'feat-login', path: worktree }],
|
|
46
|
+
})).toBe(fs.realpathSync(worktree));
|
|
47
|
+
expect(() => resolveAssignDirectory({
|
|
48
|
+
projectPath: project,
|
|
49
|
+
branch: 'missing',
|
|
50
|
+
allowedRoots: [directory],
|
|
51
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
52
|
+
worktrees: [{ branch: 'feat-login', path: worktree }],
|
|
53
|
+
})).toThrowError(expect.objectContaining({ code: ASSIGN_CODES.WORKTREE_NOT_FOUND }));
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('assignSession', () => {
|
|
58
|
+
it('creates a visible worker session and prompts it', async () => {
|
|
59
|
+
const directory = root();
|
|
60
|
+
const project = path.join(directory, 'app');
|
|
61
|
+
fs.mkdirSync(project, { recursive: true });
|
|
62
|
+
const createSession = vi.fn(async (input) => ({ data: { id: 'ses_worker' }, input }));
|
|
63
|
+
const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
|
|
64
|
+
const assigned = await assignSession({
|
|
65
|
+
prompt: 'Fix login',
|
|
66
|
+
projectPath: project,
|
|
67
|
+
title: 'Login',
|
|
68
|
+
assistant: { id: 'asst_1', name: 'Ada', providerID: 'openai', modelID: 'gpt-5.2' },
|
|
69
|
+
allowedRoots: [directory],
|
|
70
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
71
|
+
createSession,
|
|
72
|
+
promptExisting,
|
|
73
|
+
});
|
|
74
|
+
expect(assigned).toMatchObject({
|
|
75
|
+
sessionID: 'ses_worker',
|
|
76
|
+
directory: fs.realpathSync(project),
|
|
77
|
+
title: 'Login',
|
|
78
|
+
status: 'busy',
|
|
79
|
+
reused: false,
|
|
80
|
+
});
|
|
81
|
+
expect(createSession).toHaveBeenCalledWith(expect.objectContaining({
|
|
82
|
+
directory: fs.realpathSync(project),
|
|
83
|
+
title: 'Login',
|
|
84
|
+
metadata: {
|
|
85
|
+
openchamber: {
|
|
86
|
+
assigned: { from: 'contact', assistantID: 'asst_1', name: 'Ada' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
expect(createSession.mock.calls[0][0].metadata.openchamber.assistant).toBeUndefined();
|
|
91
|
+
expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({
|
|
92
|
+
sessionID: 'ses_worker',
|
|
93
|
+
directory: fs.realpathSync(project),
|
|
94
|
+
parts: [{ type: 'text', text: 'Fix login' }],
|
|
95
|
+
model: { providerID: 'openai', modelID: 'gpt-5.2' },
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('reuses an existing session without creating another', async () => {
|
|
100
|
+
const directory = root();
|
|
101
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
102
|
+
const createSession = vi.fn();
|
|
103
|
+
const promptExisting = vi.fn(async () => ({ response: { status: 204 } }));
|
|
104
|
+
const assigned = await assignSession({
|
|
105
|
+
prompt: 'Continue login',
|
|
106
|
+
sessionID: 'ses_existing',
|
|
107
|
+
directory,
|
|
108
|
+
assistant: { providerID: 'p', modelID: 'm' },
|
|
109
|
+
allowedRoots: [directory],
|
|
110
|
+
managedWorkspaceRoot: path.join(directory, 'assistant-workspaces'),
|
|
111
|
+
createSession,
|
|
112
|
+
promptExisting,
|
|
113
|
+
});
|
|
114
|
+
expect(assigned).toMatchObject({ sessionID: 'ses_existing', reused: true, status: 'busy' });
|
|
115
|
+
expect(createSession).not.toHaveBeenCalled();
|
|
116
|
+
expect(promptExisting).toHaveBeenCalledWith(expect.objectContaining({ sessionID: 'ses_existing' }));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const SENTENCE = /(?<=[.!?])\s+(?=[A-Z“"‘])/u;
|
|
2
|
+
const CONTACT_PLANNING = /\b(let me think|i should|i need to|i'll call|i will call|the user (said|wants|asked)|actually,?|hmm\.?|assign_session|list_projects|list_sessions|create_assistant|schedule_task|tool call|projectPath)\b/i;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A short spoken contact line (not chain-of-thought). Shown before a tool and kept.
|
|
6
|
+
*/
|
|
7
|
+
export function isContactSpokenPreamble(text, { maxChars = 80 } = {}) {
|
|
8
|
+
if (typeof text !== 'string') return false;
|
|
9
|
+
const next = text.replace(/\r\n/g, '\n').trim();
|
|
10
|
+
if (!next || next.length > maxChars) return false;
|
|
11
|
+
if (/\n{2,}/u.test(next)) return false;
|
|
12
|
+
if (/```/.test(next)) return false;
|
|
13
|
+
if (CONTACT_PLANNING.test(next)) return false;
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Split model output into short contact bubbles.
|
|
19
|
+
* Blank-line paragraphs first; long paragraphs split on sentence boundaries.
|
|
20
|
+
*/
|
|
21
|
+
export function splitContactBubbles(text, { maxChars = 280 } = {}) {
|
|
22
|
+
const raw = typeof text === 'string' ? text.replace(/\r\n/g, '\n').trim() : '';
|
|
23
|
+
if (!raw) return [];
|
|
24
|
+
const paragraphs = raw.split(/\n{2,}/u).map((part) => part.trim()).filter(Boolean);
|
|
25
|
+
const bubbles = [];
|
|
26
|
+
for (const paragraph of paragraphs) {
|
|
27
|
+
if (paragraph.length <= maxChars) {
|
|
28
|
+
bubbles.push(paragraph);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const sentences = paragraph.split(SENTENCE).map((part) => part.trim()).filter(Boolean);
|
|
32
|
+
let current = '';
|
|
33
|
+
for (const sentence of sentences) {
|
|
34
|
+
const next = current ? `${current} ${sentence}` : sentence;
|
|
35
|
+
if (next.length > maxChars && current) {
|
|
36
|
+
bubbles.push(current);
|
|
37
|
+
current = sentence;
|
|
38
|
+
} else {
|
|
39
|
+
current = next;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (current) bubbles.push(current);
|
|
43
|
+
}
|
|
44
|
+
return bubbles.length > 0 ? bubbles : [raw];
|
|
45
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isContactSpokenPreamble, splitContactBubbles } from './bubbles.js'
|
|
3
|
+
|
|
4
|
+
describe('splitContactBubbles', () => {
|
|
5
|
+
it('splits on blank lines into short bubbles', () => {
|
|
6
|
+
expect(splitContactBubbles('Hey.\n\nWant me to open the login session?')).toEqual([
|
|
7
|
+
'Hey.',
|
|
8
|
+
'Want me to open the login session?',
|
|
9
|
+
])
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('keeps a single paragraph together', () => {
|
|
13
|
+
expect(splitContactBubbles('Just one bubble.')).toEqual(['Just one bubble.'])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns an empty list for blank text', () => {
|
|
17
|
+
expect(splitContactBubbles(' \n\n ')).toEqual([])
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('isContactSpokenPreamble', () => {
|
|
22
|
+
it('accepts a short spoken line', () => {
|
|
23
|
+
expect(isContactSpokenPreamble('我去找一下')).toBe(true)
|
|
24
|
+
expect(isContactSpokenPreamble('On it.')).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects chain-of-thought and tool narration', () => {
|
|
28
|
+
expect(isContactSpokenPreamble('Let me think — I should match the project and call assign_session.')).toBe(false)
|
|
29
|
+
expect(isContactSpokenPreamble('I need to list_projects first.')).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('rejects long or multi-bubble text', () => {
|
|
33
|
+
expect(isContactSpokenPreamble('a'.repeat(81))).toBe(false)
|
|
34
|
+
expect(isContactSpokenPreamble('先找项目。\n\n再开会话。')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export const CONTACT_CARD_TYPES = Object.freeze(['session', 'assistant', 'schedule']);
|
|
2
|
+
const CARD_TYPES = new Set(CONTACT_CARD_TYPES);
|
|
3
|
+
// Assign / create_assistant / schedule_task auto-insert these cards.
|
|
4
|
+
// project | worktree | watch MUST reuse this slot. Do not invent a second card system.
|
|
5
|
+
|
|
6
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
const string = (value) => (typeof value === 'string' && value.trim() ? value.trim() : '');
|
|
8
|
+
|
|
9
|
+
const parseSessionCard = (value) => {
|
|
10
|
+
const sessionID = string(value.sessionID);
|
|
11
|
+
const directory = string(value.directory);
|
|
12
|
+
if (!sessionID || !directory) return null;
|
|
13
|
+
return {
|
|
14
|
+
type: 'card',
|
|
15
|
+
cardType: 'session',
|
|
16
|
+
sessionID,
|
|
17
|
+
directory,
|
|
18
|
+
title: string(value.title) || null,
|
|
19
|
+
status: string(value.status) || null,
|
|
20
|
+
branch: string(value.branch) || null,
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const parseAssistantCard = (value) => {
|
|
25
|
+
const assistantID = string(value.assistantID);
|
|
26
|
+
const name = string(value.name);
|
|
27
|
+
const providerID = string(value.providerID);
|
|
28
|
+
const modelID = string(value.modelID);
|
|
29
|
+
if (!assistantID || !name || !providerID || !modelID) return null;
|
|
30
|
+
const mode = string(value.mode) === 'stateless' ? 'stateless' : 'continuous';
|
|
31
|
+
return {
|
|
32
|
+
type: 'card',
|
|
33
|
+
cardType: 'assistant',
|
|
34
|
+
assistantID,
|
|
35
|
+
name,
|
|
36
|
+
providerID,
|
|
37
|
+
modelID,
|
|
38
|
+
mode,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const parseScheduleCard = (value) => {
|
|
43
|
+
const taskID = string(value.taskID);
|
|
44
|
+
const projectID = string(value.projectID);
|
|
45
|
+
const name = string(value.name);
|
|
46
|
+
if (!taskID || !projectID || !name) return null;
|
|
47
|
+
return {
|
|
48
|
+
type: 'card',
|
|
49
|
+
cardType: 'schedule',
|
|
50
|
+
taskID,
|
|
51
|
+
projectID,
|
|
52
|
+
name,
|
|
53
|
+
kind: string(value.kind) || null,
|
|
54
|
+
time: string(value.time) || null,
|
|
55
|
+
timezone: string(value.timezone) || null,
|
|
56
|
+
prompt: string(value.prompt) || null,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* First-class in-transcript card. Extensible via `cardType`.
|
|
62
|
+
* Session cards navigate with the existing session route, not markdown links.
|
|
63
|
+
*/
|
|
64
|
+
export function parseContactCard(value) {
|
|
65
|
+
if (!isRecord(value)) return null;
|
|
66
|
+
const cardType = string(value.cardType);
|
|
67
|
+
if (!CARD_TYPES.has(cardType)) return null;
|
|
68
|
+
if (cardType === 'session') return parseSessionCard(value);
|
|
69
|
+
if (cardType === 'assistant') return parseAssistantCard(value);
|
|
70
|
+
if (cardType === 'schedule') return parseScheduleCard(value);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseContactFilePart(value) {
|
|
75
|
+
if (!isRecord(value) || value.type !== 'file') return null;
|
|
76
|
+
const mime = string(value.mime);
|
|
77
|
+
const url = typeof value.url === 'string' && value.url.trim() ? value.url.trim() : '';
|
|
78
|
+
if (!mime || !url) return null;
|
|
79
|
+
const filename = string(value.filename);
|
|
80
|
+
return filename ? { type: 'file', mime, url, filename } : { type: 'file', mime, url };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function parseContactPart(value) {
|
|
84
|
+
if (!isRecord(value)) return null;
|
|
85
|
+
if (value.type === 'text' && typeof value.text === 'string') {
|
|
86
|
+
return { type: 'text', text: value.text };
|
|
87
|
+
}
|
|
88
|
+
if (value.type === 'file') {
|
|
89
|
+
return parseContactFilePart(value);
|
|
90
|
+
}
|
|
91
|
+
if (value.type === 'card') {
|
|
92
|
+
return parseContactCard(value);
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function serializeContactPart(part, index) {
|
|
98
|
+
return {
|
|
99
|
+
id: part.id || `oc_contact_part:${index + 1}`,
|
|
100
|
+
...part,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function contactCardIdentity(card) {
|
|
105
|
+
if (!card || card.type !== 'card') return '';
|
|
106
|
+
if (card.cardType === 'session') return `session:${card.sessionID}:${card.directory}`;
|
|
107
|
+
if (card.cardType === 'assistant') return `assistant:${card.assistantID}`;
|
|
108
|
+
if (card.cardType === 'schedule') return `schedule:${card.projectID}:${card.taskID}`;
|
|
109
|
+
return card.cardType;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const createCardPart = (cardType, input, requiredMessage) => {
|
|
113
|
+
const card = parseContactCard({ type: 'card', cardType, ...input });
|
|
114
|
+
if (!card) {
|
|
115
|
+
const error = new Error(requiredMessage);
|
|
116
|
+
error.code = 'validation_error';
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
return card;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Build a session card. Throws so insert paths fail closed. */
|
|
123
|
+
export function createSessionCardPart(input) {
|
|
124
|
+
if (!string(input?.sessionID)) {
|
|
125
|
+
const error = new Error('sessionID is required');
|
|
126
|
+
error.code = 'validation_error';
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
return createCardPart('session', input, 'Invalid session card');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createAssistantCardPart(input) {
|
|
133
|
+
if (!string(input?.assistantID)) {
|
|
134
|
+
const error = new Error('assistantID is required');
|
|
135
|
+
error.code = 'validation_error';
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
return createCardPart('assistant', input, 'Invalid assistant card');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createScheduleCardPart(input) {
|
|
142
|
+
if (!string(input?.taskID) || !string(input?.projectID)) {
|
|
143
|
+
const error = new Error('taskID and projectID are required');
|
|
144
|
+
error.code = 'validation_error';
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
return createCardPart('schedule', input, 'Invalid schedule card');
|
|
148
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { CONTACT_CARD_TYPES, createSessionCardPart, parseContactCard, parseContactPart } from './cards.js'
|
|
3
|
+
|
|
4
|
+
describe('contact cards', () => {
|
|
5
|
+
it('creates an extensible session card part', () => {
|
|
6
|
+
const part = createSessionCardPart({
|
|
7
|
+
sessionID: 'ses_1',
|
|
8
|
+
directory: '/tmp/project',
|
|
9
|
+
title: 'Fix login',
|
|
10
|
+
status: 'idle',
|
|
11
|
+
branch: 'feat-login',
|
|
12
|
+
})
|
|
13
|
+
expect(part).toEqual({
|
|
14
|
+
type: 'card',
|
|
15
|
+
cardType: 'session',
|
|
16
|
+
sessionID: 'ses_1',
|
|
17
|
+
directory: '/tmp/project',
|
|
18
|
+
title: 'Fix login',
|
|
19
|
+
status: 'idle',
|
|
20
|
+
branch: 'feat-login',
|
|
21
|
+
})
|
|
22
|
+
expect(CONTACT_CARD_TYPES).toContain('session')
|
|
23
|
+
expect(CONTACT_CARD_TYPES).toContain('assistant')
|
|
24
|
+
expect(CONTACT_CARD_TYPES).toContain('schedule')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('creates assistant and schedule cards in the same slot', () => {
|
|
28
|
+
expect(parseContactCard({
|
|
29
|
+
cardType: 'assistant',
|
|
30
|
+
assistantID: 'asst_flow',
|
|
31
|
+
name: 'FlowQA',
|
|
32
|
+
providerID: 'opencode-go',
|
|
33
|
+
modelID: 'deepseek-v4-flash',
|
|
34
|
+
})).toEqual({
|
|
35
|
+
type: 'card',
|
|
36
|
+
cardType: 'assistant',
|
|
37
|
+
assistantID: 'asst_flow',
|
|
38
|
+
name: 'FlowQA',
|
|
39
|
+
providerID: 'opencode-go',
|
|
40
|
+
modelID: 'deepseek-v4-flash',
|
|
41
|
+
mode: 'continuous',
|
|
42
|
+
})
|
|
43
|
+
expect(parseContactCard({
|
|
44
|
+
cardType: 'schedule',
|
|
45
|
+
taskID: 'task_1',
|
|
46
|
+
projectID: 'proj_1',
|
|
47
|
+
name: 'Daily ping',
|
|
48
|
+
kind: 'daily',
|
|
49
|
+
time: '18:00',
|
|
50
|
+
timezone: 'Asia/Shanghai',
|
|
51
|
+
prompt: 'ping',
|
|
52
|
+
})).toMatchObject({
|
|
53
|
+
type: 'card',
|
|
54
|
+
cardType: 'schedule',
|
|
55
|
+
taskID: 'task_1',
|
|
56
|
+
projectID: 'proj_1',
|
|
57
|
+
name: 'Daily ping',
|
|
58
|
+
kind: 'daily',
|
|
59
|
+
time: '18:00',
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('rejects unknown card types so later types reuse this slot', () => {
|
|
64
|
+
expect(parseContactCard({
|
|
65
|
+
cardType: 'not-a-card',
|
|
66
|
+
sessionID: 'ses_1',
|
|
67
|
+
directory: '/tmp/project',
|
|
68
|
+
})).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('parses contact file parts with optional filename', () => {
|
|
72
|
+
expect(parseContactPart({
|
|
73
|
+
type: 'file',
|
|
74
|
+
mime: 'image/png',
|
|
75
|
+
url: 'data:image/png;base64,aa',
|
|
76
|
+
filename: 'shot.png',
|
|
77
|
+
})).toEqual({
|
|
78
|
+
type: 'file',
|
|
79
|
+
mime: 'image/png',
|
|
80
|
+
url: 'data:image/png;base64,aa',
|
|
81
|
+
filename: 'shot.png',
|
|
82
|
+
})
|
|
83
|
+
expect(parseContactPart({
|
|
84
|
+
type: 'file',
|
|
85
|
+
mime: 'text/plain',
|
|
86
|
+
url: 'data:text/plain;base64,eA==',
|
|
87
|
+
})).toEqual({
|
|
88
|
+
type: 'file',
|
|
89
|
+
mime: 'text/plain',
|
|
90
|
+
url: 'data:text/plain;base64,eA==',
|
|
91
|
+
})
|
|
92
|
+
expect(parseContactPart({ type: 'file', mime: 'image/png' })).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('requires a sessionID for session cards', () => {
|
|
96
|
+
expect(() => createSessionCardPart({ directory: '/tmp/project' })).toThrow(/sessionID/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('treats omitted branch as null so older persisted cards still parse', () => {
|
|
100
|
+
expect(parseContactCard({
|
|
101
|
+
cardType: 'session',
|
|
102
|
+
sessionID: 'ses_1',
|
|
103
|
+
directory: '/tmp/project',
|
|
104
|
+
title: 'Fix login',
|
|
105
|
+
status: 'idle',
|
|
106
|
+
})).toEqual({
|
|
107
|
+
type: 'card',
|
|
108
|
+
cardType: 'session',
|
|
109
|
+
sessionID: 'ses_1',
|
|
110
|
+
directory: '/tmp/project',
|
|
111
|
+
title: 'Fix login',
|
|
112
|
+
status: 'idle',
|
|
113
|
+
branch: null,
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|