@swarmclawai/swarmclaw 0.3.1 → 0.4.5
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/README.md +33 -13
- package/bin/server-cmd.js +14 -7
- package/bin/swarmclaw.js +3 -1
- package/bin/update-cmd.js +120 -0
- package/next.config.ts +10 -0
- package/package.json +4 -1
- package/src/app/api/agents/[id]/route.ts +20 -18
- package/src/app/api/agents/[id]/thread/route.ts +4 -3
- package/src/app/api/agents/route.ts +8 -3
- package/src/app/api/auth/route.ts +3 -1
- package/src/app/api/claude-skills/route.ts +3 -1
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/connectors/[id]/route.ts +14 -3
- package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
- package/src/app/api/connectors/route.ts +12 -4
- package/src/app/api/credentials/[id]/route.ts +2 -1
- package/src/app/api/credentials/route.ts +5 -3
- package/src/app/api/daemon/route.ts +6 -1
- package/src/app/api/documents/route.ts +2 -2
- package/src/app/api/files/serve/route.ts +8 -0
- package/src/app/api/ip/route.ts +3 -1
- package/src/app/api/knowledge/[id]/route.ts +5 -4
- package/src/app/api/knowledge/upload/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/route.ts +11 -14
- package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
- package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
- package/src/app/api/mcp-servers/route.ts +5 -3
- package/src/app/api/memory/[id]/route.ts +9 -8
- package/src/app/api/memory/route.ts +2 -2
- package/src/app/api/memory-images/[filename]/route.ts +2 -1
- package/src/app/api/openclaw/directory/route.ts +26 -0
- package/src/app/api/openclaw/discover/route.ts +61 -0
- package/src/app/api/openclaw/sync/route.ts +30 -0
- package/src/app/api/orchestrator/graph/route.ts +25 -0
- package/src/app/api/orchestrator/run/route.ts +2 -2
- package/src/app/api/plugins/marketplace/route.ts +3 -1
- package/src/app/api/plugins/route.ts +3 -1
- package/src/app/api/projects/[id]/route.ts +55 -0
- package/src/app/api/projects/route.ts +27 -0
- package/src/app/api/providers/[id]/models/route.ts +2 -1
- package/src/app/api/providers/[id]/route.ts +13 -12
- package/src/app/api/providers/configs/route.ts +3 -1
- package/src/app/api/providers/route.ts +7 -3
- package/src/app/api/schedules/[id]/route.ts +16 -15
- package/src/app/api/schedules/[id]/run/route.ts +4 -3
- package/src/app/api/schedules/route.ts +8 -3
- package/src/app/api/secrets/[id]/route.ts +16 -17
- package/src/app/api/secrets/route.ts +5 -3
- package/src/app/api/sessions/[id]/chat/route.ts +5 -2
- package/src/app/api/sessions/[id]/clear/route.ts +2 -1
- package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
- package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
- package/src/app/api/sessions/[id]/messages/route.ts +2 -1
- package/src/app/api/sessions/[id]/retry/route.ts +2 -1
- package/src/app/api/sessions/[id]/route.ts +2 -1
- package/src/app/api/sessions/route.ts +11 -4
- package/src/app/api/settings/route.ts +3 -1
- package/src/app/api/setup/doctor/route.ts +1 -0
- package/src/app/api/setup/openclaw-device/route.ts +3 -1
- package/src/app/api/skills/[id]/route.ts +23 -21
- package/src/app/api/skills/import/route.ts +2 -2
- package/src/app/api/skills/route.ts +5 -3
- package/src/app/api/tasks/[id]/approve/route.ts +74 -0
- package/src/app/api/tasks/[id]/route.ts +9 -5
- package/src/app/api/tasks/route.ts +5 -2
- package/src/app/api/tts/stream/route.ts +48 -0
- package/src/app/api/upload/route.ts +2 -2
- package/src/app/api/uploads/[filename]/route.ts +4 -1
- package/src/app/api/usage/route.ts +3 -1
- package/src/app/api/version/route.ts +3 -1
- package/src/app/api/webhooks/[id]/route.ts +31 -32
- package/src/app/api/webhooks/route.ts +5 -3
- package/src/app/icon.svg +58 -0
- package/src/app/page.tsx +11 -26
- package/src/cli/index.js +28 -9
- package/src/cli/index.ts +45 -2
- package/src/cli/spec.js +2 -8
- package/src/components/agents/agent-card.tsx +1 -1
- package/src/components/agents/agent-list.tsx +3 -1
- package/src/components/agents/agent-sheet.tsx +166 -81
- package/src/components/chat/chat-area.tsx +71 -34
- package/src/components/chat/chat-header.tsx +141 -29
- package/src/components/chat/chat-tool-toggles.tsx +12 -53
- package/src/components/chat/message-bubble.tsx +110 -42
- package/src/components/chat/tool-call-bubble.tsx +50 -6
- package/src/components/chat/tool-request-banner.tsx +1 -9
- package/src/components/chat/voice-overlay.tsx +80 -0
- package/src/components/connectors/connector-list.tsx +9 -10
- package/src/components/connectors/connector-sheet.tsx +55 -36
- package/src/components/input/chat-input.tsx +72 -56
- package/src/components/knowledge/knowledge-list.tsx +27 -31
- package/src/components/layout/app-layout.tsx +133 -90
- package/src/components/layout/daemon-indicator.tsx +3 -5
- package/src/components/logs/log-list.tsx +5 -9
- package/src/components/mcp-servers/mcp-server-list.tsx +24 -2
- package/src/components/memory/memory-detail.tsx +1 -1
- package/src/components/plugins/plugin-list.tsx +227 -27
- package/src/components/projects/project-list.tsx +122 -0
- package/src/components/projects/project-sheet.tsx +135 -0
- package/src/components/providers/provider-list.tsx +46 -13
- package/src/components/providers/provider-sheet.tsx +0 -45
- package/src/components/runs/run-list.tsx +6 -15
- package/src/components/schedules/schedule-card.tsx +54 -4
- package/src/components/schedules/schedule-list.tsx +9 -4
- package/src/components/schedules/schedule-sheet.tsx +0 -47
- package/src/components/secrets/secrets-list.tsx +20 -2
- package/src/components/sessions/new-session-sheet.tsx +14 -15
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/sessions/session-list.tsx +7 -7
- package/src/components/shared/connector-platform-icon.tsx +26 -20
- package/src/components/shared/model-combobox.tsx +148 -0
- package/src/components/shared/settings/section-heartbeat.tsx +8 -40
- package/src/components/shared/settings/section-orchestrator.tsx +9 -11
- package/src/components/shared/settings/section-web-search.tsx +56 -0
- package/src/components/shared/settings/settings-page.tsx +73 -0
- package/src/components/skills/skill-list.tsx +262 -35
- package/src/components/skills/skill-sheet.tsx +0 -45
- package/src/components/tasks/task-board.tsx +3 -6
- package/src/components/tasks/task-card.tsx +43 -1
- package/src/components/tasks/task-list.tsx +8 -7
- package/src/components/tasks/task-sheet.tsx +0 -44
- package/src/components/usage/usage-list.tsx +12 -4
- package/src/hooks/use-continuous-speech.ts +144 -0
- package/src/hooks/use-view-router.ts +52 -0
- package/src/hooks/use-voice-conversation.ts +80 -0
- package/src/hooks/use-ws.ts +66 -0
- package/src/instrumentation.ts +2 -0
- package/src/lib/chat.ts +14 -2
- package/src/lib/id.ts +6 -0
- package/src/lib/projects.ts +13 -0
- package/src/lib/provider-sets.ts +5 -0
- package/src/lib/providers/anthropic.ts +15 -2
- package/src/lib/providers/index.ts +8 -0
- package/src/lib/providers/ollama.ts +10 -2
- package/src/lib/providers/openai.ts +42 -13
- package/src/lib/providers/openclaw.ts +11 -0
- package/src/lib/server/api-routes.test.ts +5 -6
- package/src/lib/server/build-llm.ts +17 -4
- package/src/lib/server/chat-execution.ts +57 -8
- package/src/lib/server/collection-helpers.ts +54 -0
- package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
- package/src/lib/server/connectors/bluebubbles.ts +357 -0
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/googlechat.ts +46 -7
- package/src/lib/server/connectors/manager.ts +401 -6
- package/src/lib/server/connectors/media.ts +2 -2
- package/src/lib/server/connectors/openclaw.ts +64 -0
- package/src/lib/server/connectors/pairing.test.ts +99 -0
- package/src/lib/server/connectors/pairing.ts +256 -0
- package/src/lib/server/connectors/signal.ts +1 -0
- package/src/lib/server/connectors/teams.ts +5 -5
- package/src/lib/server/connectors/types.ts +10 -0
- package/src/lib/server/context-manager.ts +1 -1
- package/src/lib/server/daemon-state.ts +3 -0
- package/src/lib/server/data-dir.ts +1 -0
- package/src/lib/server/execution-log.ts +3 -3
- package/src/lib/server/heartbeat-service.ts +67 -3
- package/src/lib/server/knowledge-db.test.ts +2 -33
- package/src/lib/server/langgraph-checkpoint.ts +274 -0
- package/src/lib/server/main-agent-loop.ts +67 -8
- package/src/lib/server/memory-db.ts +6 -6
- package/src/lib/server/openclaw-approvals.ts +105 -0
- package/src/lib/server/openclaw-sync.ts +496 -0
- package/src/lib/server/orchestrator-lg.ts +422 -20
- package/src/lib/server/orchestrator.ts +29 -9
- package/src/lib/server/process-manager.ts +2 -2
- package/src/lib/server/queue.ts +39 -13
- package/src/lib/server/scheduler.ts +2 -2
- package/src/lib/server/session-mailbox.ts +2 -2
- package/src/lib/server/session-run-manager.ts +8 -3
- package/src/lib/server/session-tools/connector.ts +51 -4
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/delegate.ts +5 -5
- package/src/lib/server/session-tools/file.ts +176 -3
- package/src/lib/server/session-tools/index.ts +4 -0
- package/src/lib/server/session-tools/memory.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
- package/src/lib/server/session-tools/sandbox.ts +197 -0
- package/src/lib/server/session-tools/search-providers.ts +270 -0
- package/src/lib/server/session-tools/session-info.ts +2 -2
- package/src/lib/server/session-tools/web.ts +47 -66
- package/src/lib/server/storage-mcp.test.ts +25 -2
- package/src/lib/server/storage.ts +36 -7
- package/src/lib/server/stream-agent-chat.ts +106 -22
- package/src/lib/server/task-result.test.ts +44 -0
- package/src/lib/server/task-result.ts +14 -0
- package/src/lib/server/task-validation.test.ts +23 -0
- package/src/lib/server/task-validation.ts +5 -3
- package/src/lib/server/ws-hub.ts +85 -0
- package/src/lib/tool-definitions.ts +44 -0
- package/src/lib/tts-stream.ts +130 -0
- package/src/lib/upload.ts +7 -1
- package/src/lib/view-routes.ts +28 -0
- package/src/lib/ws-client.ts +124 -0
- package/src/proxy.ts +3 -0
- package/src/stores/use-app-store.ts +28 -1
- package/src/stores/use-chat-store.ts +42 -14
- package/src/types/index.ts +34 -2
- package/src/app/api/agents/generate/route.ts +0 -42
- package/src/app/api/generate/info/route.ts +0 -12
- package/src/app/api/generate/route.ts +0 -106
- package/src/app/favicon.ico +0 -0
- package/src/components/shared/ai-gen-block.tsx +0 -77
|
@@ -5,29 +5,9 @@ import path from 'path'
|
|
|
5
5
|
import * as cheerio from 'cheerio'
|
|
6
6
|
import { UPLOAD_DIR } from '../storage'
|
|
7
7
|
import type { ToolBuildContext } from './context'
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// DuckDuckGo redirect-URL decoder
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
|
|
14
|
-
function decodeDuckDuckGoUrl(rawUrl: string): string {
|
|
15
|
-
if (!rawUrl) return rawUrl
|
|
16
|
-
try {
|
|
17
|
-
const url = rawUrl.startsWith('http')
|
|
18
|
-
? new URL(rawUrl)
|
|
19
|
-
: new URL(rawUrl, 'https://duckduckgo.com')
|
|
20
|
-
const uddg = url.searchParams.get('uddg')
|
|
21
|
-
if (uddg) return decodeURIComponent(uddg)
|
|
22
|
-
return url.toString()
|
|
23
|
-
} catch {
|
|
24
|
-
const fromQuery = rawUrl.match(/[?&]uddg=([^&]+)/)?.[1]
|
|
25
|
-
if (fromQuery) {
|
|
26
|
-
try { return decodeURIComponent(fromQuery) } catch { /* noop */ }
|
|
27
|
-
}
|
|
28
|
-
return rawUrl
|
|
29
|
-
}
|
|
30
|
-
}
|
|
8
|
+
import { spawnSync } from 'child_process'
|
|
9
|
+
import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
|
|
10
|
+
import { getSearchProvider } from './search-providers'
|
|
31
11
|
|
|
32
12
|
// ---------------------------------------------------------------------------
|
|
33
13
|
// Global registry of active browser instances for cleanup sweeps
|
|
@@ -86,48 +66,10 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
86
66
|
async ({ query, maxResults }) => {
|
|
87
67
|
try {
|
|
88
68
|
const limit = Math.min(maxResults || 5, 10)
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
})
|
|
94
|
-
if (!res.ok) {
|
|
95
|
-
return `Error searching web: HTTP ${res.status} ${res.statusText}`
|
|
96
|
-
}
|
|
97
|
-
const html = await res.text()
|
|
98
|
-
const $ = cheerio.load(html)
|
|
99
|
-
const results: { title: string; url: string; snippet: string }[] = []
|
|
100
|
-
|
|
101
|
-
// Primary parser: DuckDuckGo result cards
|
|
102
|
-
$('.result').each((_i, el) => {
|
|
103
|
-
if (results.length >= limit) return false
|
|
104
|
-
const link = $(el).find('a.result__a').first()
|
|
105
|
-
const rawHref = link.attr('href') || ''
|
|
106
|
-
const title = link.text().replace(/\s+/g, ' ').trim()
|
|
107
|
-
if (!rawHref || !title) return
|
|
108
|
-
const snippet = $(el).find('.result__snippet').first().text().replace(/\s+/g, ' ').trim()
|
|
109
|
-
results.push({
|
|
110
|
-
title,
|
|
111
|
-
url: decodeDuckDuckGoUrl(rawHref),
|
|
112
|
-
snippet,
|
|
113
|
-
})
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
// Fallback parser: any result__a anchors
|
|
117
|
-
if (results.length === 0) {
|
|
118
|
-
$('a.result__a').each((_i, el) => {
|
|
119
|
-
if (results.length >= limit) return false
|
|
120
|
-
const rawHref = $(el).attr('href') || ''
|
|
121
|
-
const title = $(el).text().replace(/\s+/g, ' ').trim()
|
|
122
|
-
if (!rawHref || !title) return
|
|
123
|
-
results.push({
|
|
124
|
-
title,
|
|
125
|
-
url: decodeDuckDuckGoUrl(rawHref),
|
|
126
|
-
snippet: '',
|
|
127
|
-
})
|
|
128
|
-
})
|
|
129
|
-
}
|
|
130
|
-
|
|
69
|
+
const { loadSettings } = await import('../storage')
|
|
70
|
+
const settings = loadSettings()
|
|
71
|
+
const provider = await getSearchProvider(settings)
|
|
72
|
+
const results = await provider.search(query, limit)
|
|
131
73
|
return results.length > 0
|
|
132
74
|
? JSON.stringify(results, null, 2)
|
|
133
75
|
: 'No results found.'
|
|
@@ -137,7 +79,7 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
137
79
|
},
|
|
138
80
|
{
|
|
139
81
|
name: 'web_search',
|
|
140
|
-
description: 'Search the web
|
|
82
|
+
description: 'Search the web. Returns an array of results with title, url, and snippet.',
|
|
141
83
|
schema: z.object({
|
|
142
84
|
query: z.string().describe('Search query'),
|
|
143
85
|
maxResults: z.number().optional().describe('Maximum results to return (default 5, max 10)'),
|
|
@@ -404,5 +346,44 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
404
346
|
)
|
|
405
347
|
}
|
|
406
348
|
|
|
349
|
+
// ---- openclaw_browser (CLI passthrough) -----------------------------------
|
|
350
|
+
|
|
351
|
+
if (bctx.hasTool('browser') || bctx.hasTool('openclaw_browser')) {
|
|
352
|
+
const openclawPath = findBinaryOnPath('openclaw') || findBinaryOnPath('clawdbot')
|
|
353
|
+
if (openclawPath) {
|
|
354
|
+
tools.push(
|
|
355
|
+
tool(
|
|
356
|
+
async ({ command, args: cmdArgs }) => {
|
|
357
|
+
try {
|
|
358
|
+
const spawnArgs = ['browser', command, '--json']
|
|
359
|
+
if (cmdArgs) spawnArgs.push(...cmdArgs.split(/\s+/).filter(Boolean))
|
|
360
|
+
const result = spawnSync(openclawPath, spawnArgs, {
|
|
361
|
+
encoding: 'utf-8',
|
|
362
|
+
timeout: 60_000,
|
|
363
|
+
maxBuffer: MAX_OUTPUT,
|
|
364
|
+
})
|
|
365
|
+
const stdout = (result.stdout || '').trim()
|
|
366
|
+
const stderr = (result.stderr || '').trim()
|
|
367
|
+
if (result.status !== 0) {
|
|
368
|
+
return `Error (exit ${result.status}): ${stderr || stdout || 'unknown error'}`
|
|
369
|
+
}
|
|
370
|
+
return truncate(stdout || '(no output)', MAX_OUTPUT)
|
|
371
|
+
} catch (err: any) {
|
|
372
|
+
return `Error: ${err.message}`
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'openclaw_browser',
|
|
377
|
+
description: 'Control a browser through the OpenClaw CLI. Requires openclaw/clawdbot CLI on PATH. Passes through to `openclaw browser <command> --json`.',
|
|
378
|
+
schema: z.object({
|
|
379
|
+
command: z.string().describe('Browser command (navigate, screenshot, click, type, evaluate, etc.)'),
|
|
380
|
+
args: z.string().optional().describe('Additional arguments as a space-separated string'),
|
|
381
|
+
}),
|
|
382
|
+
},
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
407
388
|
return tools
|
|
408
389
|
}
|
|
@@ -33,8 +33,15 @@ function loadMcpServers(): Record<string, any> {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function saveMcpServers(m: Record<string, any>) {
|
|
36
|
+
const existingRows = db.prepare(`SELECT id FROM ${TABLE}`).all() as { id: string }[]
|
|
37
|
+
const nextIds = new Set(Object.keys(m))
|
|
38
|
+
const toDelete = existingRows.map((r) => r.id).filter((id) => !nextIds.has(id))
|
|
36
39
|
const upsert = db.prepare(`INSERT OR REPLACE INTO ${TABLE} (id, data) VALUES (?, ?)`)
|
|
40
|
+
const del = db.prepare(`DELETE FROM ${TABLE} WHERE id = ?`)
|
|
37
41
|
const transaction = db.transaction(() => {
|
|
42
|
+
for (const id of toDelete) {
|
|
43
|
+
del.run(id)
|
|
44
|
+
}
|
|
38
45
|
for (const [id, val] of Object.entries(m)) {
|
|
39
46
|
upsert.run(id, JSON.stringify(val))
|
|
40
47
|
}
|
|
@@ -75,8 +82,10 @@ describe('MCP server storage', () => {
|
|
|
75
82
|
})
|
|
76
83
|
|
|
77
84
|
it('loadMcpServers returns all saved configs', () => {
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
saveMcpServers({
|
|
86
|
+
'srv-1': { id: 'srv-1', name: 'First' },
|
|
87
|
+
'srv-2': { id: 'srv-2', name: 'Second' },
|
|
88
|
+
})
|
|
80
89
|
|
|
81
90
|
const all = loadMcpServers()
|
|
82
91
|
assert.ok('srv-1' in all)
|
|
@@ -102,6 +111,20 @@ describe('MCP server storage', () => {
|
|
|
102
111
|
assert.equal(count, 1)
|
|
103
112
|
})
|
|
104
113
|
|
|
114
|
+
it('saveMcpServers removes records omitted from the next save payload', () => {
|
|
115
|
+
saveMcpServers({
|
|
116
|
+
'srv-a': { id: 'srv-a', name: 'A' },
|
|
117
|
+
'srv-b': { id: 'srv-b', name: 'B' },
|
|
118
|
+
})
|
|
119
|
+
saveMcpServers({
|
|
120
|
+
'srv-b': { id: 'srv-b', name: 'B2' },
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const all = loadMcpServers()
|
|
124
|
+
assert.equal('srv-a' in all, false)
|
|
125
|
+
assert.equal(all['srv-b'].name, 'B2')
|
|
126
|
+
})
|
|
127
|
+
|
|
105
128
|
it('deleteMcpServer removes the record', () => {
|
|
106
129
|
saveMcpServers({ 'srv-d': { id: 'srv-d', name: 'ToDelete' } })
|
|
107
130
|
deleteMcpServer('srv-d')
|
|
@@ -4,11 +4,11 @@ import crypto from 'crypto'
|
|
|
4
4
|
import os from 'os'
|
|
5
5
|
import Database from 'better-sqlite3'
|
|
6
6
|
|
|
7
|
-
import { DATA_DIR } from './data-dir'
|
|
8
|
-
export const UPLOAD_DIR = path.join(
|
|
7
|
+
import { DATA_DIR, WORKSPACE_DIR } from './data-dir'
|
|
8
|
+
export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
|
|
9
9
|
|
|
10
10
|
// Ensure directories exist
|
|
11
|
-
for (const dir of [DATA_DIR, UPLOAD_DIR]) {
|
|
11
|
+
for (const dir of [DATA_DIR, UPLOAD_DIR, WORKSPACE_DIR]) {
|
|
12
12
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -43,6 +43,7 @@ const COLLECTIONS = [
|
|
|
43
43
|
'model_overrides',
|
|
44
44
|
'mcp_servers',
|
|
45
45
|
'webhook_logs',
|
|
46
|
+
'projects',
|
|
46
47
|
] as const
|
|
47
48
|
|
|
48
49
|
for (const table of COLLECTIONS) {
|
|
@@ -65,8 +66,8 @@ function readCollectionRaw(table: string): Map<string, string> {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
function getCollectionRawCache(table: string): Map<string, string> {
|
|
68
|
-
|
|
69
|
-
|
|
69
|
+
// Always reload from SQLite so concurrent Next.js workers/processes
|
|
70
|
+
// observe each other's writes immediately.
|
|
70
71
|
const loaded = readCollectionRaw(table)
|
|
71
72
|
collectionCache.set(table, loaded)
|
|
72
73
|
return loaded
|
|
@@ -87,26 +88,43 @@ function loadCollection(table: string): Record<string, any> {
|
|
|
87
88
|
|
|
88
89
|
function saveCollection(table: string, data: Record<string, any>) {
|
|
89
90
|
const current = getCollectionRawCache(table)
|
|
91
|
+
const next = new Map<string, string>()
|
|
90
92
|
const toUpsert: Array<[string, string]> = []
|
|
93
|
+
const toDelete: string[] = []
|
|
91
94
|
|
|
92
95
|
for (const [id, val] of Object.entries(data)) {
|
|
93
96
|
const serialized = JSON.stringify(val)
|
|
94
97
|
if (typeof serialized !== 'string') continue
|
|
98
|
+
next.set(id, serialized)
|
|
95
99
|
if (current.get(id) !== serialized) {
|
|
96
100
|
toUpsert.push([id, serialized])
|
|
97
101
|
}
|
|
98
|
-
current.set(id, serialized)
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
|
|
104
|
+
for (const id of current.keys()) {
|
|
105
|
+
if (!next.has(id)) toDelete.push(id)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!toUpsert.length && !toDelete.length) return
|
|
102
109
|
|
|
103
110
|
const transaction = db.transaction(() => {
|
|
111
|
+
if (toDelete.length) {
|
|
112
|
+
const del = db.prepare(`DELETE FROM ${table} WHERE id = ?`)
|
|
113
|
+
for (const id of toDelete) del.run(id)
|
|
114
|
+
}
|
|
104
115
|
const upsert = db.prepare(`INSERT OR REPLACE INTO ${table} (id, data) VALUES (?, ?)`)
|
|
105
116
|
for (const [id, serialized] of toUpsert) {
|
|
106
117
|
upsert.run(id, serialized)
|
|
107
118
|
}
|
|
108
119
|
})
|
|
109
120
|
transaction()
|
|
121
|
+
|
|
122
|
+
for (const id of toDelete) {
|
|
123
|
+
current.delete(id)
|
|
124
|
+
}
|
|
125
|
+
for (const [id, serialized] of next.entries()) {
|
|
126
|
+
current.set(id, serialized)
|
|
127
|
+
}
|
|
110
128
|
}
|
|
111
129
|
|
|
112
130
|
function deleteCollectionItem(table: string, id: string) {
|
|
@@ -559,6 +577,17 @@ export function saveModelOverrides(m: Record<string, string[]>) {
|
|
|
559
577
|
saveCollection('model_overrides', m)
|
|
560
578
|
}
|
|
561
579
|
|
|
580
|
+
// --- Projects ---
|
|
581
|
+
export function loadProjects(): Record<string, any> {
|
|
582
|
+
return loadCollection('projects')
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export function saveProjects(s: Record<string, any>) {
|
|
586
|
+
saveCollection('projects', s)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function deleteProject(id: string) { deleteCollectionItem('projects', id) }
|
|
590
|
+
|
|
562
591
|
// --- Skills ---
|
|
563
592
|
export function loadSkills(): Record<string, any> {
|
|
564
593
|
return loadCollection('skills')
|
|
@@ -15,6 +15,7 @@ interface StreamAgentChatOpts {
|
|
|
15
15
|
session: Session
|
|
16
16
|
message: string
|
|
17
17
|
imagePath?: string
|
|
18
|
+
attachedFiles?: string[]
|
|
18
19
|
apiKey: string | null
|
|
19
20
|
systemPrompt?: string
|
|
20
21
|
write: (data: string) => void
|
|
@@ -38,6 +39,7 @@ function buildToolCapabilityLines(enabledTools: string[]): string[] {
|
|
|
38
39
|
if (enabledTools.includes('codex_cli')) lines.push('- Codex delegation is available (`delegate_to_codex_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
|
|
39
40
|
if (enabledTools.includes('opencode_cli')) lines.push('- OpenCode delegation is available (`delegate_to_opencode_cli`) for deep coding/refactor tasks. Resume IDs may be returned via `[delegate_meta]`.')
|
|
40
41
|
if (enabledTools.includes('memory')) lines.push('- Long-term memory is available (`memory_tool`) to store and recall durable context.')
|
|
42
|
+
if (enabledTools.includes('sandbox')) lines.push('- Sandboxed code execution is available (`sandbox_exec`). Write and run JS/TS (Deno) or Python scripts in an isolated environment. Output includes stdout, stderr, and any files created as downloadable artifacts.')
|
|
41
43
|
if (enabledTools.includes('manage_agents')) lines.push('- Agent management is available (`manage_agents`) to create or adjust specialist agents.')
|
|
42
44
|
if (enabledTools.includes('manage_tasks')) lines.push('- Task management is available (`manage_tasks`) to create and track execution plans.')
|
|
43
45
|
if (enabledTools.includes('manage_schedules')) lines.push('- Schedule management is available (`manage_schedules`) for recurring/ongoing runs.')
|
|
@@ -162,15 +164,24 @@ export interface StreamAgentChatResult {
|
|
|
162
164
|
}
|
|
163
165
|
|
|
164
166
|
export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<StreamAgentChatResult> {
|
|
165
|
-
const { session, message, imagePath, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
|
|
167
|
+
const { session, message, imagePath, attachedFiles, apiKey, systemPrompt, write, history, fallbackCredentialIds, signal } = opts
|
|
166
168
|
|
|
167
169
|
// fallbackCredentialIds is intentionally accepted for compatibility with caller signatures.
|
|
168
170
|
void fallbackCredentialIds
|
|
171
|
+
|
|
172
|
+
// Resolve agent's thinking level for provider-native params
|
|
173
|
+
let agentThinkingLevel: 'minimal' | 'low' | 'medium' | 'high' | undefined
|
|
174
|
+
if (session.agentId) {
|
|
175
|
+
const agentsForThinking = loadAgents()
|
|
176
|
+
agentThinkingLevel = agentsForThinking[session.agentId]?.thinkingLevel
|
|
177
|
+
}
|
|
178
|
+
|
|
169
179
|
const llm = buildChatModel({
|
|
170
180
|
provider: session.provider,
|
|
171
181
|
model: session.model,
|
|
172
182
|
apiKey,
|
|
173
183
|
apiEndpoint: session.apiEndpoint,
|
|
184
|
+
thinkingLevel: agentThinkingLevel,
|
|
174
185
|
})
|
|
175
186
|
|
|
176
187
|
// Build stateModifier
|
|
@@ -226,6 +237,17 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
226
237
|
stateModifierParts.push('You are a capable AI assistant with tool access. Be execution-oriented and outcome-focused.')
|
|
227
238
|
}
|
|
228
239
|
|
|
240
|
+
// Thinking level guidance (applies to all providers via system prompt)
|
|
241
|
+
if (agentThinkingLevel) {
|
|
242
|
+
const thinkingGuidance: Record<string, string> = {
|
|
243
|
+
minimal: 'Be direct and concise. Skip extended analysis.',
|
|
244
|
+
low: 'Keep reasoning brief. Focus on key conclusions.',
|
|
245
|
+
medium: 'Provide moderate depth of analysis and reasoning.',
|
|
246
|
+
high: 'Think deeply and thoroughly. Show detailed reasoning.',
|
|
247
|
+
}
|
|
248
|
+
stateModifierParts.push(`## Reasoning Depth\n${thinkingGuidance[agentThinkingLevel]}`)
|
|
249
|
+
}
|
|
250
|
+
|
|
229
251
|
if ((session.tools || []).includes('memory') && session.agentId) {
|
|
230
252
|
try {
|
|
231
253
|
const memDb = getMemoryDb()
|
|
@@ -310,7 +332,6 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
310
332
|
const allToolIds = [
|
|
311
333
|
'shell', 'files', 'edit_file', 'process', 'web_search', 'web_fetch', 'browser', 'memory',
|
|
312
334
|
'claude_code', 'codex_cli', 'opencode_cli',
|
|
313
|
-
'orchestrator',
|
|
314
335
|
'manage_agents', 'manage_tasks', 'manage_schedules', 'manage_skills',
|
|
315
336
|
'manage_documents', 'manage_webhooks', 'manage_connectors', 'manage_sessions', 'manage_secrets',
|
|
316
337
|
]
|
|
@@ -318,13 +339,9 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
318
339
|
const mcpDisabled = agentMcpDisabledTools ?? []
|
|
319
340
|
const allDisabled = [...disabled, ...mcpDisabled]
|
|
320
341
|
if (allDisabled.length > 0) {
|
|
321
|
-
const delegateNote = disabled.includes('orchestrator')
|
|
322
|
-
? '\n\nIMPORTANT: The `delegate_to_agent` tool requires the `orchestrator` capability to be enabled. You must request access to `orchestrator` before you can delegate work to other agents.'
|
|
323
|
-
: ''
|
|
324
342
|
stateModifierParts.push(
|
|
325
343
|
`## Disabled Tools\nThe following tools exist but are not enabled for you: ${allDisabled.join(', ')}.\n` +
|
|
326
|
-
'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.'
|
|
327
|
-
delegateNote,
|
|
344
|
+
'If you need one of these to complete a task, use the `request_tool_access` tool to ask the user for permission.',
|
|
328
345
|
)
|
|
329
346
|
}
|
|
330
347
|
}
|
|
@@ -354,25 +371,82 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
354
371
|
const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
|
|
355
372
|
const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
|
|
356
373
|
|
|
357
|
-
function
|
|
358
|
-
if (!
|
|
374
|
+
async function buildContentForFile(filePath: string): Promise<{ type: string; [k: string]: any } | string | null> {
|
|
375
|
+
if (!fs.existsSync(filePath)) {
|
|
376
|
+
console.log(`[stream-agent-chat] FILE NOT FOUND: ${filePath}`)
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
const name = filePath.split('/').pop() || 'file'
|
|
359
380
|
if (IMAGE_EXTS.test(filePath)) {
|
|
360
|
-
const
|
|
381
|
+
const buf = fs.readFileSync(filePath)
|
|
382
|
+
if (buf.length === 0) {
|
|
383
|
+
console.warn(`[stream-agent-chat] Image file is empty: ${filePath}`)
|
|
384
|
+
return `[Attached image: ${name} — file is empty]`
|
|
385
|
+
}
|
|
386
|
+
const data = buf.toString('base64')
|
|
361
387
|
const ext = filePath.split('.').pop()?.toLowerCase() || 'png'
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
]
|
|
388
|
+
// Detect actual MIME from magic bytes (fall back to extension-based)
|
|
389
|
+
let mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
|
|
390
|
+
if (buf[0] === 0xFF && buf[1] === 0xD8) mimeType = 'image/jpeg'
|
|
391
|
+
else if (buf[0] === 0x89 && buf[1] === 0x50) mimeType = 'image/png'
|
|
392
|
+
else if (buf[0] === 0x47 && buf[1] === 0x49) mimeType = 'image/gif'
|
|
393
|
+
else if (buf[0] === 0x52 && buf[1] === 0x49) mimeType = 'image/webp'
|
|
394
|
+
return { type: 'image_url', image_url: { url: `data:${mimeType};base64,${data}`, detail: 'auto' } }
|
|
395
|
+
}
|
|
396
|
+
if (filePath.endsWith('.pdf')) {
|
|
397
|
+
try {
|
|
398
|
+
// @ts-ignore — pdf-parse types
|
|
399
|
+
const pdfParse = (await import(/* webpackIgnore: true */ 'pdf-parse')).default
|
|
400
|
+
const buf = fs.readFileSync(filePath)
|
|
401
|
+
const result = await pdfParse(buf)
|
|
402
|
+
const pdfText = (result.text || '').trim()
|
|
403
|
+
if (!pdfText) return `[Attached PDF: ${name} — no extractable text]`
|
|
404
|
+
// Truncate very large PDFs to avoid token limits
|
|
405
|
+
const maxChars = 100_000
|
|
406
|
+
const truncated = pdfText.length > maxChars ? pdfText.slice(0, maxChars) + '\n\n[... truncated]' : pdfText
|
|
407
|
+
return `[Attached PDF: ${name} (${result.numpages} pages)]\n\n${truncated}`
|
|
408
|
+
} catch {
|
|
409
|
+
return `[Attached PDF: ${name} — could not extract text]`
|
|
410
|
+
}
|
|
367
411
|
}
|
|
368
|
-
if (TEXT_EXTS.test(filePath)
|
|
412
|
+
if (TEXT_EXTS.test(filePath)) {
|
|
369
413
|
try {
|
|
370
414
|
const fileContent = fs.readFileSync(filePath, 'utf-8')
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
} catch { return text }
|
|
415
|
+
return `[Attached file: ${name}]\n\n${fileContent}`
|
|
416
|
+
} catch { return `[Attached file: ${name} — read error]` }
|
|
374
417
|
}
|
|
375
|
-
return `[Attached file: ${
|
|
418
|
+
return `[Attached file: ${name}]`
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function buildLangChainContent(text: string, filePath?: string, extraFiles?: string[]): Promise<any> {
|
|
422
|
+
const filePaths: string[] = []
|
|
423
|
+
if (filePath) filePaths.push(filePath)
|
|
424
|
+
if (extraFiles?.length) {
|
|
425
|
+
for (const f of extraFiles) {
|
|
426
|
+
if (f && !filePaths.includes(f)) filePaths.push(f)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (!filePaths.length) return text
|
|
430
|
+
|
|
431
|
+
const parts: any[] = []
|
|
432
|
+
const textParts: string[] = []
|
|
433
|
+
for (const fp of filePaths) {
|
|
434
|
+
const content = await buildContentForFile(fp)
|
|
435
|
+
if (!content) continue
|
|
436
|
+
if (typeof content === 'string') {
|
|
437
|
+
textParts.push(content)
|
|
438
|
+
} else {
|
|
439
|
+
parts.push(content)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const combinedText = textParts.length
|
|
444
|
+
? `${textParts.join('\n\n')}\n\n${text}`
|
|
445
|
+
: text
|
|
446
|
+
|
|
447
|
+
if (parts.length === 0) return combinedText
|
|
448
|
+
parts.push({ type: 'text', text: combinedText })
|
|
449
|
+
return parts
|
|
376
450
|
}
|
|
377
451
|
|
|
378
452
|
// Auto-compaction: prune old history if approaching context window limit
|
|
@@ -397,14 +471,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
397
471
|
const langchainMessages: Array<HumanMessage | AIMessage> = []
|
|
398
472
|
for (const m of effectiveHistory.slice(-20)) {
|
|
399
473
|
if (m.role === 'user') {
|
|
400
|
-
langchainMessages.push(new HumanMessage({ content: buildLangChainContent(m.text, m.imagePath) }))
|
|
474
|
+
langchainMessages.push(new HumanMessage({ content: await buildLangChainContent(m.text, m.imagePath, m.attachedFiles) }))
|
|
401
475
|
} else {
|
|
402
476
|
langchainMessages.push(new AIMessage({ content: m.text }))
|
|
403
477
|
}
|
|
404
478
|
}
|
|
405
479
|
|
|
406
480
|
// Add current message
|
|
407
|
-
|
|
481
|
+
const currentContent = await buildLangChainContent(message, imagePath, attachedFiles)
|
|
482
|
+
langchainMessages.push(new HumanMessage({ content: currentContent }))
|
|
408
483
|
|
|
409
484
|
let fullText = ''
|
|
410
485
|
let lastSegment = ''
|
|
@@ -557,6 +632,15 @@ export async function streamAgentChat(opts: StreamAgentChatOpts): Promise<Stream
|
|
|
557
632
|
// Plugin hooks: afterAgentComplete
|
|
558
633
|
await pluginMgr.runHook('afterAgentComplete', { session, response: fullText })
|
|
559
634
|
|
|
635
|
+
// OpenClaw auto-sync: push memory if enabled
|
|
636
|
+
try {
|
|
637
|
+
const { loadSyncConfig, pushMemoryToOpenClaw } = await import('./openclaw-sync')
|
|
638
|
+
const syncConfig = loadSyncConfig()
|
|
639
|
+
if (syncConfig.autoSyncMemory) {
|
|
640
|
+
pushMemoryToOpenClaw(session.agentId || undefined)
|
|
641
|
+
}
|
|
642
|
+
} catch { /* OpenClaw sync not available — ignore */ }
|
|
643
|
+
|
|
560
644
|
// Clean up browser and other session resources
|
|
561
645
|
await cleanup()
|
|
562
646
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { extractTaskResult } from './task-result'
|
|
4
|
+
|
|
5
|
+
describe('extractTaskResult', () => {
|
|
6
|
+
it('limits artifact extraction to messages from the current run window', () => {
|
|
7
|
+
const session = {
|
|
8
|
+
messages: [
|
|
9
|
+
{
|
|
10
|
+
role: 'assistant',
|
|
11
|
+
time: 1_000,
|
|
12
|
+
text: 'old run artifact: /api/uploads/wiki-old.png',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
role: 'assistant',
|
|
16
|
+
time: 2_000,
|
|
17
|
+
text: 'new run artifact: /api/uploads/wiki-new.png',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result = extractTaskResult(session, 'done', { sinceTime: 1_500 })
|
|
23
|
+
assert.deepEqual(result.artifacts.map((a) => a.url), ['/api/uploads/wiki-new.png'])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('excludes messages without timestamps when sinceTime is provided', () => {
|
|
27
|
+
const session = {
|
|
28
|
+
messages: [
|
|
29
|
+
{
|
|
30
|
+
role: 'assistant',
|
|
31
|
+
text: 'undated artifact: /api/uploads/undated.png',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
role: 'assistant',
|
|
35
|
+
time: 5_000,
|
|
36
|
+
text: 'dated artifact: /api/uploads/dated.png',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = extractTaskResult(session, 'done', { sinceTime: 4_000 })
|
|
42
|
+
assert.deepEqual(result.artifacts.map((a) => a.url), ['/api/uploads/dated.png'])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -43,6 +43,7 @@ function classifyArtifact(filename: string): Artifact['type'] {
|
|
|
43
43
|
interface MessageLike {
|
|
44
44
|
role?: string
|
|
45
45
|
text?: string
|
|
46
|
+
time?: number
|
|
46
47
|
imageUrl?: string
|
|
47
48
|
imagePath?: string
|
|
48
49
|
toolEvents?: Array<{ name?: string; output?: string }>
|
|
@@ -52,6 +53,10 @@ interface SessionLike {
|
|
|
52
53
|
messages?: MessageLike[]
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
interface ExtractTaskResultOptions {
|
|
57
|
+
sinceTime?: number | null
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
// ---------------------------------------------------------------------------
|
|
56
61
|
// Core extraction
|
|
57
62
|
// ---------------------------------------------------------------------------
|
|
@@ -64,9 +69,13 @@ interface SessionLike {
|
|
|
64
69
|
export function extractTaskResult(
|
|
65
70
|
session: SessionLike | null | undefined,
|
|
66
71
|
rawResultText: string | null | undefined,
|
|
72
|
+
options?: ExtractTaskResultOptions,
|
|
67
73
|
): TaskResult {
|
|
68
74
|
const seen = new Set<string>()
|
|
69
75
|
const artifacts: Artifact[] = []
|
|
76
|
+
const sinceTime = typeof options?.sinceTime === 'number' && Number.isFinite(options.sinceTime)
|
|
77
|
+
? options.sinceTime
|
|
78
|
+
: null
|
|
70
79
|
|
|
71
80
|
function addUrl(raw: string) {
|
|
72
81
|
const url = stripSandbox(raw)
|
|
@@ -79,6 +88,11 @@ export function extractTaskResult(
|
|
|
79
88
|
// Walk session messages to collect all artifact URLs
|
|
80
89
|
if (Array.isArray(session?.messages)) {
|
|
81
90
|
for (const msg of session.messages) {
|
|
91
|
+
if (sinceTime !== null) {
|
|
92
|
+
const msgTime = typeof msg.time === 'number' && Number.isFinite(msg.time) ? msg.time : null
|
|
93
|
+
if (msgTime === null || msgTime < sinceTime) continue
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
// Explicit image fields
|
|
83
97
|
if (msg.imageUrl) addUrl(msg.imageUrl)
|
|
84
98
|
if (msg.imagePath) {
|
|
@@ -25,3 +25,26 @@ test('validateTaskCompletion accepts screenshot delivery tasks with upload artif
|
|
|
25
25
|
|
|
26
26
|
assert.equal(validation.ok, true)
|
|
27
27
|
})
|
|
28
|
+
|
|
29
|
+
test('validateTaskCompletion accepts concise non-implementation result summaries', () => {
|
|
30
|
+
const validation = validateTaskCompletion({
|
|
31
|
+
title: 'Answer greeting',
|
|
32
|
+
description: 'Respond to a basic hello prompt.',
|
|
33
|
+
result: 'Hello! How can I help you today?',
|
|
34
|
+
error: null,
|
|
35
|
+
} as Partial<BoardTask>)
|
|
36
|
+
|
|
37
|
+
assert.equal(validation.ok, true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('validateTaskCompletion still enforces stricter minimum for implementation tasks', () => {
|
|
41
|
+
const validation = validateTaskCompletion({
|
|
42
|
+
title: 'Fix retry bug',
|
|
43
|
+
description: 'Implement queue retry fixes and verify.',
|
|
44
|
+
result: 'Patched queue retry bug.',
|
|
45
|
+
error: null,
|
|
46
|
+
} as Partial<BoardTask>)
|
|
47
|
+
|
|
48
|
+
assert.equal(validation.ok, false)
|
|
49
|
+
assert.ok(validation.reasons.some((reason) => reason.includes('Result summary is too short')))
|
|
50
|
+
})
|
|
@@ -11,7 +11,8 @@ interface TaskCompletionValidationOptions {
|
|
|
11
11
|
report?: TaskReportArtifact | null
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const
|
|
14
|
+
const MIN_RESULT_CHARS_IMPLEMENTATION = 40
|
|
15
|
+
const MIN_RESULT_CHARS_GENERIC = 20
|
|
15
16
|
|
|
16
17
|
const WEAK_RESULT_PATTERNS: RegExp[] = [
|
|
17
18
|
/what can i help you with/i,
|
|
@@ -45,12 +46,14 @@ export function validateTaskCompletion(
|
|
|
45
46
|
const result = normalizeText(task.result)
|
|
46
47
|
const error = normalizeText(task.error)
|
|
47
48
|
const report = options.report || null
|
|
49
|
+
const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
|
|
48
50
|
|
|
49
51
|
if (error) reasons.push('Task has a non-empty error field.')
|
|
50
52
|
|
|
51
53
|
if (!result) reasons.push('Result summary is empty.')
|
|
52
54
|
else {
|
|
53
|
-
|
|
55
|
+
const minChars = implementationTask ? MIN_RESULT_CHARS_IMPLEMENTATION : MIN_RESULT_CHARS_GENERIC
|
|
56
|
+
if (result.length < minChars) reasons.push(`Result summary is too short (${result.length} chars; min ${minChars}).`)
|
|
54
57
|
if (WEAK_RESULT_PATTERNS.some((rx) => rx.test(result))) {
|
|
55
58
|
reasons.push('Result contains placeholder/planning language instead of completion evidence.')
|
|
56
59
|
}
|
|
@@ -58,7 +61,6 @@ export function validateTaskCompletion(
|
|
|
58
61
|
|
|
59
62
|
// If task description/title suggests implementation work, require concrete evidence in
|
|
60
63
|
// the result summary OR task report.
|
|
61
|
-
const implementationTask = IMPLEMENTATION_HINT.test(title) || IMPLEMENTATION_HINT.test(description)
|
|
62
64
|
const hasResultEvidence = EXECUTION_EVIDENCE.test(result)
|
|
63
65
|
const hasReportEvidence = report?.evidence.hasEvidence === true
|
|
64
66
|
if (implementationTask && !hasResultEvidence && !hasReportEvidence) {
|