@bibobonking/opensol 0.0.0-stage → 3.0.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/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart session compaction.
|
|
3
|
+
*
|
|
4
|
+
* When the context window is getting full, this module asks the AI to produce
|
|
5
|
+
* a detailed summary of everything that has happened so far — tools called,
|
|
6
|
+
* files changed, decisions made, open tasks — and replaces the old messages
|
|
7
|
+
* with that summary plus a compact system note. The summary is intentionally
|
|
8
|
+
* thorough so the AI does not lose track of ongoing work.
|
|
9
|
+
*
|
|
10
|
+
* The compacted session contains:
|
|
11
|
+
* 1. A synthetic user message: the original FIRST user message (task framing)
|
|
12
|
+
* 2. A synthetic assistant message: the AI-generated summary
|
|
13
|
+
* 3. A synthetic user message: a brief handoff note telling the AI to continue
|
|
14
|
+
*
|
|
15
|
+
* This keeps the conversation coherent from the provider's perspective while
|
|
16
|
+
* drastically reducing token consumption.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { AssistantMessage, Message, UserMessage } from '../types'
|
|
20
|
+
import { useChats, createId } from '../store/chats'
|
|
21
|
+
import { useSettings, activeModelId } from '../store/settings'
|
|
22
|
+
import { findModel } from '../store/models'
|
|
23
|
+
import { streamModel } from './model'
|
|
24
|
+
import { EMPTY_HANDLERS } from './adapters/shared'
|
|
25
|
+
|
|
26
|
+
const COMPACT_SYSTEM = `You are a session compactor. Your ONLY job is to produce a detailed, structured summary of the conversation you are given. This summary will replace the original messages so the AI agent can continue the task without losing important context.
|
|
27
|
+
|
|
28
|
+
The summary MUST include, in clear sections:
|
|
29
|
+
|
|
30
|
+
## Task & Goal
|
|
31
|
+
What the user originally asked for. The full scope, requirements, and any constraints they stated.
|
|
32
|
+
|
|
33
|
+
## Work Completed So Far
|
|
34
|
+
Every meaningful action taken: files read, files created or modified (list each file path and what changed), commands run (and their output), API calls made, decisions reached, bugs found and fixed.
|
|
35
|
+
|
|
36
|
+
## Current State
|
|
37
|
+
What is the exact state of the workspace right now? Which files are in what state? What has been verified to work? What is broken or incomplete?
|
|
38
|
+
|
|
39
|
+
## Open Tasks & Next Steps
|
|
40
|
+
What remains to be done? List concrete next actions with enough detail that the agent can pick up exactly where things left off.
|
|
41
|
+
|
|
42
|
+
## Key Facts & Decisions
|
|
43
|
+
Important technical facts discovered during the session: library versions, APIs used, architectural decisions made, pitfalls encountered.
|
|
44
|
+
|
|
45
|
+
## Files Modified or Created
|
|
46
|
+
An exhaustive list of every file path that was touched and a one-line description of what was done.
|
|
47
|
+
|
|
48
|
+
Be exhaustive and precise. Do not summarize tool output vaguely — include actual file paths, function names, error messages, command outputs. The agent continuing the work must be able to act on this summary without access to the original messages.`
|
|
49
|
+
|
|
50
|
+
/** True while a compact is in progress for a given session id. */
|
|
51
|
+
const compacting = new Set<string>()
|
|
52
|
+
|
|
53
|
+
export function isCompacting(sessionId: string): boolean {
|
|
54
|
+
return compacting.has(sessionId)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Runs the compaction for `sessionId`.
|
|
59
|
+
*
|
|
60
|
+
* Returns true if compaction succeeded, false if it was skipped or failed.
|
|
61
|
+
* Never throws — errors are surfaced via the maxcode:notice event.
|
|
62
|
+
*/
|
|
63
|
+
export async function compactSession(sessionId: string): Promise<boolean> {
|
|
64
|
+
if (compacting.has(sessionId)) return false
|
|
65
|
+
compacting.add(sessionId)
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const session = useChats.getState().sessions.find((s) => s.id === sessionId)
|
|
69
|
+
if (!session || session.messages.length < 4) return false
|
|
70
|
+
|
|
71
|
+
const settings = useSettings.getState()
|
|
72
|
+
const modelId = activeModelId(settings)
|
|
73
|
+
if (!modelId) return false
|
|
74
|
+
|
|
75
|
+
const model = findModel(settings.provider, modelId)
|
|
76
|
+
|
|
77
|
+
// Build a full conversation transcript for the summariser.
|
|
78
|
+
// We include assistant text and tool results so the summariser has the
|
|
79
|
+
// complete picture of what happened.
|
|
80
|
+
const transcript = buildTranscript(session.messages)
|
|
81
|
+
|
|
82
|
+
// The summariser request is sent as a plain user message so we don't have
|
|
83
|
+
// to worry about tool call parsing or streaming UI updates.
|
|
84
|
+
const summaryHistory: Message[] = [
|
|
85
|
+
{
|
|
86
|
+
id: createId('msg'),
|
|
87
|
+
role: 'user',
|
|
88
|
+
content: `Below is a complete transcript of a coding session. Produce the detailed summary as instructed.\n\n${transcript}`,
|
|
89
|
+
createdAt: Date.now(),
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
let summaryContent = ''
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const result = await streamModel({
|
|
97
|
+
settings,
|
|
98
|
+
model,
|
|
99
|
+
history: summaryHistory,
|
|
100
|
+
system: COMPACT_SYSTEM,
|
|
101
|
+
mode: 'build',
|
|
102
|
+
signal: new AbortController().signal,
|
|
103
|
+
handlers: {
|
|
104
|
+
...EMPTY_HANDLERS,
|
|
105
|
+
onText: (delta) => {
|
|
106
|
+
summaryContent += delta
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
if (result.content.trim()) summaryContent = result.content
|
|
111
|
+
} catch (error) {
|
|
112
|
+
window.dispatchEvent(
|
|
113
|
+
new CustomEvent('maxcode:notice', {
|
|
114
|
+
detail: {
|
|
115
|
+
message: `Auto-compact failed: ${error instanceof Error ? error.message : 'Unknown error'}. Continuing with full context.`,
|
|
116
|
+
tone: 'error',
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!summaryContent.trim()) return false
|
|
124
|
+
|
|
125
|
+
// Find the first user message to preserve the original task framing.
|
|
126
|
+
const firstUser = session.messages.find((m): m is UserMessage => m.role === 'user')
|
|
127
|
+
const now = Date.now()
|
|
128
|
+
|
|
129
|
+
// Build the replacement message set.
|
|
130
|
+
const compactedMessages: Message[] = []
|
|
131
|
+
|
|
132
|
+
// 1. Original task (first user message, preserved exactly).
|
|
133
|
+
if (firstUser) {
|
|
134
|
+
compactedMessages.push({ ...firstUser })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. AI-generated summary of all the work done.
|
|
138
|
+
const summaryMessage: AssistantMessage = {
|
|
139
|
+
id: createId('msg'),
|
|
140
|
+
role: 'assistant',
|
|
141
|
+
content: summaryContent.trim(),
|
|
142
|
+
reasoning: '',
|
|
143
|
+
toolCalls: [],
|
|
144
|
+
round: 0,
|
|
145
|
+
model: modelId,
|
|
146
|
+
provider: settings.provider,
|
|
147
|
+
createdAt: now,
|
|
148
|
+
durationMs: 0,
|
|
149
|
+
}
|
|
150
|
+
compactedMessages.push(summaryMessage)
|
|
151
|
+
|
|
152
|
+
// 3. Handoff note telling the AI to resume work.
|
|
153
|
+
const handoffNote = buildHandoffNote(session.messages)
|
|
154
|
+
const handoffMessage: UserMessage = {
|
|
155
|
+
id: createId('msg'),
|
|
156
|
+
role: 'user',
|
|
157
|
+
content: handoffNote,
|
|
158
|
+
createdAt: now,
|
|
159
|
+
}
|
|
160
|
+
compactedMessages.push(handoffMessage)
|
|
161
|
+
|
|
162
|
+
// Atomically replace the session's messages with the compacted set.
|
|
163
|
+
useChats.getState().updateSession(sessionId, {
|
|
164
|
+
messages: compactedMessages,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
window.dispatchEvent(
|
|
168
|
+
new CustomEvent('maxcode:notice', {
|
|
169
|
+
detail: {
|
|
170
|
+
message: '✦ Context compacted — session summary saved, continuing with fresh context.',
|
|
171
|
+
tone: 'success',
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return true
|
|
177
|
+
} finally {
|
|
178
|
+
compacting.delete(sessionId)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Helpers
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function buildTranscript(messages: Message[]): string {
|
|
187
|
+
const parts: string[] = []
|
|
188
|
+
for (const message of messages) {
|
|
189
|
+
if (message.role === 'user') {
|
|
190
|
+
const attachmentNote =
|
|
191
|
+
message.attachments && message.attachments.length > 0
|
|
192
|
+
? `\n[Attachments: ${message.attachments.map((a) => a.name).join(', ')}]`
|
|
193
|
+
: ''
|
|
194
|
+
parts.push(`[USER]\n${message.content}${attachmentNote}`)
|
|
195
|
+
} else if (message.role === 'assistant') {
|
|
196
|
+
const lines: string[] = []
|
|
197
|
+
if (message.reasoning.trim()) lines.push(`<thinking>\n${message.reasoning.trim()}\n</thinking>`)
|
|
198
|
+
if (message.content.trim()) lines.push(message.content.trim())
|
|
199
|
+
for (const call of message.toolCalls) {
|
|
200
|
+
lines.push(`<tool_call name="${call.name}">\n${JSON.stringify(call.args, null, 2)}\n</tool_call>`)
|
|
201
|
+
}
|
|
202
|
+
if (lines.length > 0) parts.push(`[ASSISTANT]\n${lines.join('\n\n')}`)
|
|
203
|
+
} else if (message.role === 'tool') {
|
|
204
|
+
const status = message.status === 'done' ? 'OK' : message.status.toUpperCase()
|
|
205
|
+
const result = (message.result ?? '').slice(0, 8_000)
|
|
206
|
+
const truncated = (message.result ?? '').length > 8_000 ? '\n… (truncated)' : ''
|
|
207
|
+
parts.push(`[TOOL: ${message.call.name} — ${status}]\n${result}${truncated}`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return parts.join('\n\n---\n\n')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildHandoffNote(messages: Message[]): string {
|
|
214
|
+
// Find the last user message to understand what was most recently requested.
|
|
215
|
+
const reversed = [...messages].reverse()
|
|
216
|
+
const lastUser = reversed.find((m): m is UserMessage => m.role === 'user')
|
|
217
|
+
const lastUserContent = lastUser?.content?.trim() ?? ''
|
|
218
|
+
|
|
219
|
+
// Count completed tool calls for context.
|
|
220
|
+
const toolCalls = messages.filter((m) => m.role === 'tool').length
|
|
221
|
+
const userTurns = messages.filter((m) => m.role === 'user').length
|
|
222
|
+
|
|
223
|
+
return [
|
|
224
|
+
`[CONTEXT COMPACTED — ${userTurns} user turns, ${toolCalls} tool calls summarised above]`,
|
|
225
|
+
'',
|
|
226
|
+
'The summary above contains the full record of work done so far. Please:',
|
|
227
|
+
'1. Read the summary carefully before acting — it contains critical state information.',
|
|
228
|
+
'2. Continue exactly where you left off.',
|
|
229
|
+
'3. Do not repeat work already marked as completed in the summary.',
|
|
230
|
+
lastUserContent ? `\nThe most recent task/request was:\n${lastUserContent}` : '',
|
|
231
|
+
]
|
|
232
|
+
.filter((line) => line !== undefined)
|
|
233
|
+
.join('\n')
|
|
234
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { kernel } from '../core/kernel'
|
|
2
|
+
|
|
3
|
+
export function stopRun(sessionId: string): void {
|
|
4
|
+
if (kernel.agentLoop) {
|
|
5
|
+
kernel.agentLoop.stopRun(sessionId)
|
|
6
|
+
} else {
|
|
7
|
+
queueMicrotask(() => kernel.agentLoop?.stopRun(sessionId))
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stopAllRuns(): void {
|
|
12
|
+
if (kernel.agentLoop) {
|
|
13
|
+
kernel.agentLoop.stopAllRuns()
|
|
14
|
+
} else {
|
|
15
|
+
queueMicrotask(() => kernel.agentLoop?.stopAllRuns())
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Queue one agent run for the submitted user message. A prompt sent while a turn
|
|
21
|
+
* is streaming waits its turn instead of starting a second overlapping reply.
|
|
22
|
+
*/
|
|
23
|
+
export function runAgent(sessionId: string, userMessageId?: string): void {
|
|
24
|
+
if (kernel.agentLoop) {
|
|
25
|
+
kernel.agentLoop.runAgent(sessionId, userMessageId)
|
|
26
|
+
} else {
|
|
27
|
+
queueMicrotask(() => kernel.agentLoop?.runAgent(sessionId, userMessageId))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function regenerateLastTurn(sessionId: string): boolean {
|
|
32
|
+
if (kernel.agentLoop) {
|
|
33
|
+
return kernel.agentLoop.regenerateLastTurn(sessionId)
|
|
34
|
+
}
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AdapterRequest, AdapterResult } from './adapters/shared'
|
|
2
|
+
import { streamAnthropic } from './adapters/anthropic'
|
|
3
|
+
import { streamGemini } from './adapters/gemini'
|
|
4
|
+
import { streamOpenAI } from './adapters/openai'
|
|
5
|
+
import { streamOpenAIResponses } from './adapters/openai-responses'
|
|
6
|
+
import { apiKindFor } from '../store/models'
|
|
7
|
+
|
|
8
|
+
export function streamModel(request: AdapterRequest): Promise<AdapterResult> {
|
|
9
|
+
const kind = apiKindFor(request.settings.provider, request.settings.model, request.model)
|
|
10
|
+
switch (kind) {
|
|
11
|
+
case 'anthropic':
|
|
12
|
+
return streamAnthropic(request)
|
|
13
|
+
case 'gemini':
|
|
14
|
+
return streamGemini(request)
|
|
15
|
+
case 'openai-responses':
|
|
16
|
+
return streamOpenAIResponses(request)
|
|
17
|
+
default:
|
|
18
|
+
return streamOpenAI(request)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { AgentMode, AppSettings } from '../types'
|
|
2
|
+
import { activeModelId } from '../store/settings'
|
|
3
|
+
import { useWorkspace } from '../store/workspace'
|
|
4
|
+
import { kernel } from '../core/kernel'
|
|
5
|
+
|
|
6
|
+
function workspaceContext(): string {
|
|
7
|
+
const state = useWorkspace.getState()
|
|
8
|
+
const root = state.root || state.info.root
|
|
9
|
+
if (!root) {
|
|
10
|
+
return 'No folder is open yet. Ask the user to choose a workspace folder in the Workspace panel before using file, shell or git tools.'
|
|
11
|
+
}
|
|
12
|
+
const topLevel = state.entries
|
|
13
|
+
.filter((entry) => !entry.path.includes('/'))
|
|
14
|
+
.slice(0, 40)
|
|
15
|
+
.map((entry) => `${entry.path}${entry.type === 'dir' ? '/' : ''}`)
|
|
16
|
+
const git = state.git.available
|
|
17
|
+
? `${state.git.branch || 'detached HEAD'} · ${state.git.entries.length} changed file${state.git.entries.length === 1 ? '' : 's'}`
|
|
18
|
+
: 'not a git repository'
|
|
19
|
+
return [
|
|
20
|
+
`Root: ${root}`,
|
|
21
|
+
`Git: ${git}`,
|
|
22
|
+
topLevel.length > 0 ? `Top level: ${topLevel.join(', ')}` : 'The folder is empty.',
|
|
23
|
+
state.truncated ? 'The file tree was truncated; use list_files or grep for deeper paths.' : '',
|
|
24
|
+
]
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join('\n')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildSystemPrompt(settings: AppSettings, mode: AgentMode = settings.mode): string {
|
|
30
|
+
if (kernel.systemPrompt) {
|
|
31
|
+
return kernel.systemPrompt.assemble(settings, mode)
|
|
32
|
+
}
|
|
33
|
+
const chunks: string[] = [settings.systemPrompt.trim()]
|
|
34
|
+
|
|
35
|
+
const skills = settings.skills.filter((skill) => skill.enabled)
|
|
36
|
+
if (skills.length > 0) {
|
|
37
|
+
chunks.push(
|
|
38
|
+
[
|
|
39
|
+
'## Loaded skills',
|
|
40
|
+
'Apply these skills when the task matches their purpose. They refine the working rules above; they never override safety, tool permissions or plan mode.',
|
|
41
|
+
...skills.map(
|
|
42
|
+
(skill) =>
|
|
43
|
+
`<skill name="${skill.name}">\nPurpose: ${skill.description}\n${skill.instructions.trim()}\n</skill>`,
|
|
44
|
+
),
|
|
45
|
+
].join('\n\n'),
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
chunks.push(
|
|
50
|
+
[
|
|
51
|
+
'## Workspace',
|
|
52
|
+
'You operate on the real folder below through the local OpenSol daemon. Tools return real output; never invent file contents, command results, URLs or deployments.',
|
|
53
|
+
workspaceContext(),
|
|
54
|
+
].join('\n\n'),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
chunks.push(
|
|
58
|
+
mode === 'plan'
|
|
59
|
+
? [
|
|
60
|
+
'## Mode: Plan (read-only)',
|
|
61
|
+
'Investigate and design. Do not modify anything.',
|
|
62
|
+
'- Allowed: read_file, list_files, grep, web_search, read_url, read-only shell commands and read-only git actions.',
|
|
63
|
+
'- Forbidden: write_file, edit_file, rewrite_file, git add/commit/init/checkout, and shell commands that change files, install packages or change system state. The daemon rejects these.',
|
|
64
|
+
'- Read the relevant source before proposing changes. Grep for call sites and inspect the real files.',
|
|
65
|
+
'- Finish with a concrete, ordered implementation plan naming files and functions. State assumptions and open questions plainly.',
|
|
66
|
+
'- Never pretend an edit was made or a check was run.',
|
|
67
|
+
].join('\n')
|
|
68
|
+
: [
|
|
69
|
+
'## Mode: Build',
|
|
70
|
+
'You may inspect and change the real workspace.',
|
|
71
|
+
'- Inspect before editing: list files, read the source, grep for call sites, check git status.',
|
|
72
|
+
'- Make the smallest coherent change. Do not reformat unrelated code, add speculative abstractions or invent APIs.',
|
|
73
|
+
'- After a mutation, inspect the result. Run the project’s real typecheck, lint, tests and build when they exist, and read the output.',
|
|
74
|
+
'- Never claim a check passed without running it. If a check cannot run, say why.',
|
|
75
|
+
'- Ask before destructive actions, force pushes, credential changes or anything that leaves the workspace.',
|
|
76
|
+
].join('\n'),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
chunks.push(
|
|
80
|
+
[
|
|
81
|
+
'## Session context',
|
|
82
|
+
`- Model: ${activeModelId(settings) || 'unset'}`,
|
|
83
|
+
`- Date: ${new Date().toISOString().slice(0, 10)}`,
|
|
84
|
+
`- Mode: ${mode}`,
|
|
85
|
+
settings.webSearchEnabled
|
|
86
|
+
? '- Web search is enabled. Use it when facts may be current or when the user asks for sources.'
|
|
87
|
+
: '- Web search is turned off for this session.',
|
|
88
|
+
'- Use relative paths from the workspace root. Do not use /workspace paths.',
|
|
89
|
+
].join('\n'),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return chunks.filter(Boolean).join('\n\n')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function buildSubagentPrompt(settings: AppSettings): string {
|
|
96
|
+
return [
|
|
97
|
+
buildSystemPrompt(settings, 'plan'),
|
|
98
|
+
[
|
|
99
|
+
'## Subagent',
|
|
100
|
+
'You are a focused read-only investigator. Work only on the assigned task.',
|
|
101
|
+
'- Inspect the real workspace with read-only file, grep, shell and web tools.',
|
|
102
|
+
'- Do not edit files, change git state or run commands that mutate the machine. The daemon enforces this.',
|
|
103
|
+
'- Be concise: return findings, exact file paths and line references, and any uncertainty.',
|
|
104
|
+
'- Do not repeat the task back. Do not invent evidence. If something cannot be verified, say so.',
|
|
105
|
+
].join('\n'),
|
|
106
|
+
].join('\n\n')
|
|
107
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { createTurnQueue, historyUpTo, lastUserMessageId, turnHistory } from './queue.ts'
|
|
4
|
+
|
|
5
|
+
const u = (id) => ({ id, role: 'user', content: 'hi', createdAt: 0 })
|
|
6
|
+
const a = (id) => ({ id, role: 'assistant', content: '', reasoning: '', toolCalls: [], round: 0, createdAt: 0 })
|
|
7
|
+
const t = (id) => ({ id, role: 'tool', call: { id: `${id}-c`, name: 'read_file', args: {} }, status: 'done', round: 0, createdAt: 0 })
|
|
8
|
+
|
|
9
|
+
test('an answer cannot see a later user prompt', () => {
|
|
10
|
+
const messages = [u('u1'), u('u2')]
|
|
11
|
+
assert.deepEqual(historyUpTo(messages, 'u1')?.map((m) => m.id), ['u1'])
|
|
12
|
+
assert.equal(historyUpTo(messages, 'gone'), null)
|
|
13
|
+
assert.equal(lastUserMessageId(messages), 'u2')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('turn history stops at the next user message but keeps later rounds', () => {
|
|
17
|
+
const messages = [u('u1'), a('a1'), t('t1'), a('a2'), u('u2')]
|
|
18
|
+
assert.deepEqual(turnHistory(messages, 'u1')?.map((m) => m.id), ['u1', 'a1', 't1', 'a2'])
|
|
19
|
+
assert.deepEqual(turnHistory(messages, 'u2')?.map((m) => m.id), ['u1', 'a1', 't1', 'a2', 'u2'])
|
|
20
|
+
assert.equal(turnHistory(messages, 'gone'), null)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('queued turns run in submission order, never concurrently', async () => {
|
|
24
|
+
const events = []
|
|
25
|
+
let active = 0
|
|
26
|
+
let peak = 0
|
|
27
|
+
const queue = createTurnQueue(async (_sessionId, id) => {
|
|
28
|
+
active += 1
|
|
29
|
+
peak = Math.max(peak, active)
|
|
30
|
+
events.push(`start:${id}`)
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, 5))
|
|
32
|
+
events.push(`end:${id}`)
|
|
33
|
+
active -= 1
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const first = queue.push('s', 'u1')
|
|
37
|
+
const second = queue.push('s', 'u2')
|
|
38
|
+
assert.equal(queue.isBusy('s'), true)
|
|
39
|
+
await Promise.all([first, second])
|
|
40
|
+
|
|
41
|
+
assert.deepEqual(events, ['start:u1', 'end:u1', 'start:u2', 'end:u2'])
|
|
42
|
+
assert.equal(peak, 1)
|
|
43
|
+
assert.equal(queue.isIdle(), true)
|
|
44
|
+
assert.equal(queue.isBusy('s'), false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('separate sessions run independently and a failed turn still drains the queue', async () => {
|
|
48
|
+
const done = []
|
|
49
|
+
const queue = createTurnQueue(async (sessionId, id) => {
|
|
50
|
+
if (id === 'bad') throw new Error('provider failed')
|
|
51
|
+
done.push(`${sessionId}:${id}`)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
await Promise.all([queue.push('a', 'bad'), queue.push('a', 'ok'), queue.push('b', 'ok')])
|
|
55
|
+
assert.deepEqual(done.sort(), ['a:ok', 'b:ok'])
|
|
56
|
+
assert.equal(queue.isIdle(), true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('stop settles the turn so the next queued prompt still runs', async () => {
|
|
60
|
+
const ran = []
|
|
61
|
+
const queue = createTurnQueue(async (_sessionId, id) => {
|
|
62
|
+
if (id === 'stop') return
|
|
63
|
+
ran.push(id)
|
|
64
|
+
})
|
|
65
|
+
await queue.push('s', 'stop')
|
|
66
|
+
await queue.push('s', 'after')
|
|
67
|
+
assert.deepEqual(ran, ['after'])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('clear drops waiting turns but not the running one', async () => {
|
|
71
|
+
const ran = []
|
|
72
|
+
let release
|
|
73
|
+
const gate = new Promise((resolve) => {
|
|
74
|
+
release = resolve
|
|
75
|
+
})
|
|
76
|
+
const queue = createTurnQueue(async (_sessionId, id) => {
|
|
77
|
+
ran.push(id)
|
|
78
|
+
if (id === 'first') await gate
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const running = queue.push('s', 'first')
|
|
82
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
83
|
+
const waiting = queue.push('s', 'second')
|
|
84
|
+
queue.clear('s')
|
|
85
|
+
release()
|
|
86
|
+
await Promise.all([running, waiting])
|
|
87
|
+
|
|
88
|
+
assert.deepEqual(ran, ['first'])
|
|
89
|
+
assert.equal(queue.isIdle(), true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('clearAll drops queued turns in every session but lets the running one finish', async () => {
|
|
93
|
+
const ran = []
|
|
94
|
+
let release
|
|
95
|
+
const gate = new Promise((resolve) => {
|
|
96
|
+
release = resolve
|
|
97
|
+
})
|
|
98
|
+
const queue = createTurnQueue(async (sessionId, id) => {
|
|
99
|
+
ran.push(`${sessionId}:${id}`)
|
|
100
|
+
if (id === 'a1' || id === 'b1') await gate
|
|
101
|
+
})
|
|
102
|
+
const running = queue.push('a', 'a1')
|
|
103
|
+
const queuedA = queue.push('a', 'a2')
|
|
104
|
+
const runningB = queue.push('b', 'b1')
|
|
105
|
+
const queuedB = queue.push('b', 'b2')
|
|
106
|
+
await Promise.resolve()
|
|
107
|
+
queue.clearAll()
|
|
108
|
+
release()
|
|
109
|
+
await Promise.all([running, runningB, queuedA, queuedB])
|
|
110
|
+
assert.deepEqual(ran.sort(), ['a:a1', 'b:b1'])
|
|
111
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Message, UserMessage } from '../types'
|
|
2
|
+
|
|
3
|
+
/** Provider history for one turn: everything through its submitted user message. */
|
|
4
|
+
export function historyUpTo(messages: Message[], userMessageId: string): Message[] | null {
|
|
5
|
+
const end = messages.findIndex((message) => message.id === userMessageId)
|
|
6
|
+
if (end === -1) return null
|
|
7
|
+
return messages.slice(0, end + 1)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* History for a turn mid-run. A prompt submitted while this turn is streaming is
|
|
12
|
+
* already in the store, so the slice stops before the next user message.
|
|
13
|
+
*/
|
|
14
|
+
export function turnHistory(messages: Message[], userMessageId: string): Message[] | null {
|
|
15
|
+
const start = messages.findIndex((message) => message.id === userMessageId)
|
|
16
|
+
if (start === -1) return null
|
|
17
|
+
for (let index = start + 1; index < messages.length; index += 1) {
|
|
18
|
+
if (messages[index].role === 'user') return messages.slice(0, index)
|
|
19
|
+
}
|
|
20
|
+
return messages.slice()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function lastUserMessageId(messages: Message[]): string | null {
|
|
24
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
25
|
+
const message = messages[index]
|
|
26
|
+
if (message.role === 'user') return (message as UserMessage).id
|
|
27
|
+
}
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TurnQueue {
|
|
32
|
+
/** Runs after every turn already queued for this session. */
|
|
33
|
+
push(sessionId: string, userMessageId: string): Promise<void>
|
|
34
|
+
/** Drops turns that have not started yet; the running one keeps its abort signal. */
|
|
35
|
+
clear(sessionId: string): void
|
|
36
|
+
/** Drops every turn that has not started yet, in every session. */
|
|
37
|
+
clearAll(): void
|
|
38
|
+
isBusy(sessionId: string): boolean
|
|
39
|
+
isIdle(): boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface Lane {
|
|
43
|
+
chain: Promise<void>
|
|
44
|
+
/** Bumped by clear(); a queued run whose token is stale never starts. */
|
|
45
|
+
token: object
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One run per session at a time, in submission order. A prompt sent mid-turn is
|
|
50
|
+
* queued instead of dropped, and each run only sees history up to its own turn.
|
|
51
|
+
*/
|
|
52
|
+
export function createTurnQueue(run: (sessionId: string, userMessageId: string) => Promise<void>): TurnQueue {
|
|
53
|
+
const lanes = new Map<string, Lane>()
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
isBusy: (sessionId) => lanes.has(sessionId),
|
|
57
|
+
isIdle: () => lanes.size === 0,
|
|
58
|
+
clear(sessionId) {
|
|
59
|
+
const lane = lanes.get(sessionId)
|
|
60
|
+
if (!lane) return
|
|
61
|
+
lanes.set(sessionId, { chain: lane.chain, token: {} })
|
|
62
|
+
},
|
|
63
|
+
clearAll() {
|
|
64
|
+
for (const [sessionId, lane] of lanes) lanes.set(sessionId, { chain: lane.chain, token: {} })
|
|
65
|
+
},
|
|
66
|
+
push(sessionId, userMessageId) {
|
|
67
|
+
const lane = lanes.get(sessionId) ?? { chain: Promise.resolve(), token: {} }
|
|
68
|
+
const token = lane.token
|
|
69
|
+
const next = lane.chain.then(
|
|
70
|
+
() => (lanes.get(sessionId)?.token === token ? run(sessionId, userMessageId) : undefined),
|
|
71
|
+
() => (lanes.get(sessionId)?.token === token ? run(sessionId, userMessageId) : undefined),
|
|
72
|
+
)
|
|
73
|
+
const done = next.then(
|
|
74
|
+
() => undefined,
|
|
75
|
+
() => undefined,
|
|
76
|
+
)
|
|
77
|
+
lanes.set(sessionId, { chain: done, token })
|
|
78
|
+
void done.then(() => {
|
|
79
|
+
if (lanes.get(sessionId)?.chain === done) lanes.delete(sessionId)
|
|
80
|
+
})
|
|
81
|
+
return done
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|