@swarmclawai/swarmclaw 0.6.7 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/README.md +82 -39
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +19 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -0
  8. package/src/app/api/clawhub/install/route.ts +2 -2
  9. package/src/app/api/eval/run/route.ts +37 -0
  10. package/src/app/api/eval/scenarios/route.ts +24 -0
  11. package/src/app/api/eval/suite/route.ts +29 -0
  12. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  13. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  14. package/src/app/api/memory/graph/route.ts +46 -0
  15. package/src/app/api/memory/route.ts +36 -5
  16. package/src/app/api/notifications/route.ts +3 -0
  17. package/src/app/api/plugins/install/route.ts +57 -5
  18. package/src/app/api/plugins/marketplace/route.ts +73 -22
  19. package/src/app/api/plugins/route.ts +61 -1
  20. package/src/app/api/plugins/ui/route.ts +34 -0
  21. package/src/app/api/sessions/[id]/checkpoints/route.ts +31 -0
  22. package/src/app/api/sessions/[id]/restore/route.ts +36 -0
  23. package/src/app/api/settings/route.ts +62 -0
  24. package/src/app/api/setup/doctor/route.ts +22 -5
  25. package/src/app/api/souls/[id]/route.ts +65 -0
  26. package/src/app/api/souls/route.ts +70 -0
  27. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  28. package/src/app/api/tasks/[id]/route.ts +16 -3
  29. package/src/app/api/tasks/route.ts +10 -2
  30. package/src/app/api/usage/route.ts +9 -2
  31. package/src/app/globals.css +27 -0
  32. package/src/app/page.tsx +10 -5
  33. package/src/cli/index.js +37 -0
  34. package/src/components/activity/activity-feed.tsx +9 -2
  35. package/src/components/agents/agent-avatar.tsx +5 -1
  36. package/src/components/agents/agent-card.tsx +55 -9
  37. package/src/components/agents/agent-sheet.tsx +112 -34
  38. package/src/components/agents/inspector-panel.tsx +1 -1
  39. package/src/components/agents/soul-library-picker.tsx +84 -13
  40. package/src/components/auth/access-key-gate.tsx +63 -54
  41. package/src/components/auth/user-picker.tsx +37 -32
  42. package/src/components/chat/activity-moment.tsx +2 -0
  43. package/src/components/chat/chat-area.tsx +11 -0
  44. package/src/components/chat/chat-header.tsx +69 -25
  45. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  46. package/src/components/chat/checkpoint-timeline.tsx +112 -0
  47. package/src/components/chat/code-block.tsx +3 -1
  48. package/src/components/chat/exec-approval-card.tsx +8 -1
  49. package/src/components/chat/message-bubble.tsx +164 -4
  50. package/src/components/chat/message-list.tsx +46 -4
  51. package/src/components/chat/session-approval-card.tsx +80 -0
  52. package/src/components/chat/session-debug-panel.tsx +106 -84
  53. package/src/components/chat/streaming-bubble.tsx +6 -5
  54. package/src/components/chat/task-approval-card.tsx +78 -0
  55. package/src/components/chat/thinking-indicator.tsx +48 -12
  56. package/src/components/chat/tool-call-bubble.tsx +3 -0
  57. package/src/components/chat/tool-request-banner.tsx +39 -20
  58. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  59. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  60. package/src/components/connectors/connector-list.tsx +33 -11
  61. package/src/components/connectors/connector-sheet.tsx +37 -7
  62. package/src/components/home/home-view.tsx +54 -24
  63. package/src/components/input/chat-input.tsx +22 -1
  64. package/src/components/knowledge/knowledge-list.tsx +17 -18
  65. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  66. package/src/components/layout/app-layout.tsx +87 -19
  67. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  68. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  69. package/src/components/memory/memory-browser.tsx +73 -45
  70. package/src/components/memory/memory-graph-view.tsx +203 -0
  71. package/src/components/memory/memory-list.tsx +20 -13
  72. package/src/components/plugins/plugin-list.tsx +214 -60
  73. package/src/components/plugins/plugin-sheet.tsx +119 -24
  74. package/src/components/projects/project-list.tsx +17 -9
  75. package/src/components/providers/provider-list.tsx +21 -6
  76. package/src/components/providers/provider-sheet.tsx +42 -25
  77. package/src/components/runs/run-list.tsx +17 -13
  78. package/src/components/schedules/schedule-card.tsx +10 -3
  79. package/src/components/schedules/schedule-list.tsx +2 -2
  80. package/src/components/schedules/schedule-sheet.tsx +28 -9
  81. package/src/components/secrets/secret-sheet.tsx +7 -2
  82. package/src/components/secrets/secrets-list.tsx +18 -5
  83. package/src/components/sessions/new-session-sheet.tsx +183 -376
  84. package/src/components/sessions/session-card.tsx +10 -2
  85. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  86. package/src/components/shared/command-palette.tsx +13 -5
  87. package/src/components/shared/empty-state.tsx +20 -8
  88. package/src/components/shared/hint-tip.tsx +31 -0
  89. package/src/components/shared/notification-center.tsx +134 -86
  90. package/src/components/shared/profile-sheet.tsx +4 -0
  91. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  92. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  93. package/src/components/shared/settings/section-runtime-loop.tsx +149 -4
  94. package/src/components/skills/clawhub-browser.tsx +1 -0
  95. package/src/components/skills/skill-list.tsx +31 -12
  96. package/src/components/skills/skill-sheet.tsx +20 -7
  97. package/src/components/tasks/approvals-panel.tsx +224 -0
  98. package/src/components/tasks/task-board.tsx +20 -12
  99. package/src/components/tasks/task-card.tsx +21 -7
  100. package/src/components/tasks/task-column.tsx +4 -3
  101. package/src/components/tasks/task-list.tsx +1 -1
  102. package/src/components/tasks/task-sheet.tsx +130 -1
  103. package/src/components/ui/dialog.tsx +1 -0
  104. package/src/components/ui/sheet.tsx +1 -0
  105. package/src/components/usage/metrics-dashboard.tsx +72 -48
  106. package/src/components/wallets/wallet-panel.tsx +65 -41
  107. package/src/components/wallets/wallet-section.tsx +9 -3
  108. package/src/components/webhooks/webhook-list.tsx +21 -12
  109. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  110. package/src/lib/approval-display.test.ts +45 -0
  111. package/src/lib/approval-display.ts +62 -0
  112. package/src/lib/clipboard.ts +38 -0
  113. package/src/lib/memory.ts +8 -0
  114. package/src/lib/providers/claude-cli.ts +5 -3
  115. package/src/lib/providers/index.ts +67 -21
  116. package/src/lib/runtime-loop.ts +3 -2
  117. package/src/lib/server/approvals.ts +150 -0
  118. package/src/lib/server/chat-execution.ts +319 -74
  119. package/src/lib/server/chatroom-helpers.ts +63 -5
  120. package/src/lib/server/chatroom-orchestration.ts +74 -0
  121. package/src/lib/server/clawhub-client.ts +82 -6
  122. package/src/lib/server/connectors/manager.ts +27 -1
  123. package/src/lib/server/context-manager.ts +132 -50
  124. package/src/lib/server/cost.test.ts +73 -0
  125. package/src/lib/server/cost.ts +165 -34
  126. package/src/lib/server/daemon-state.ts +112 -1
  127. package/src/lib/server/data-dir.ts +18 -1
  128. package/src/lib/server/eval/runner.ts +126 -0
  129. package/src/lib/server/eval/scenarios.ts +218 -0
  130. package/src/lib/server/eval/scorer.ts +96 -0
  131. package/src/lib/server/eval/store.ts +37 -0
  132. package/src/lib/server/eval/types.ts +48 -0
  133. package/src/lib/server/execution-log.ts +12 -8
  134. package/src/lib/server/guardian.ts +34 -0
  135. package/src/lib/server/heartbeat-service.ts +53 -1
  136. package/src/lib/server/integrity-monitor.ts +208 -0
  137. package/src/lib/server/langgraph-checkpoint.ts +10 -0
  138. package/src/lib/server/link-understanding.ts +55 -0
  139. package/src/lib/server/llm-response-cache.test.ts +102 -0
  140. package/src/lib/server/llm-response-cache.ts +227 -0
  141. package/src/lib/server/main-agent-loop.ts +115 -16
  142. package/src/lib/server/main-session.ts +6 -3
  143. package/src/lib/server/mcp-conformance.test.ts +18 -0
  144. package/src/lib/server/mcp-conformance.ts +233 -0
  145. package/src/lib/server/memory-db.ts +193 -19
  146. package/src/lib/server/memory-retrieval.test.ts +56 -0
  147. package/src/lib/server/mmr.ts +73 -0
  148. package/src/lib/server/orchestrator-lg.ts +7 -1
  149. package/src/lib/server/orchestrator.ts +4 -3
  150. package/src/lib/server/plugins.ts +662 -132
  151. package/src/lib/server/process-manager.ts +18 -0
  152. package/src/lib/server/query-expansion.ts +57 -0
  153. package/src/lib/server/queue.ts +280 -11
  154. package/src/lib/server/runtime-settings.ts +9 -0
  155. package/src/lib/server/session-run-manager.test.ts +23 -0
  156. package/src/lib/server/session-run-manager.ts +32 -2
  157. package/src/lib/server/session-tools/canvas.ts +85 -50
  158. package/src/lib/server/session-tools/chatroom.ts +130 -127
  159. package/src/lib/server/session-tools/connector.ts +233 -454
  160. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  161. package/src/lib/server/session-tools/crud.ts +84 -7
  162. package/src/lib/server/session-tools/delegate.ts +351 -752
  163. package/src/lib/server/session-tools/discovery.ts +198 -0
  164. package/src/lib/server/session-tools/edit_file.ts +82 -0
  165. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  166. package/src/lib/server/session-tools/file.ts +257 -425
  167. package/src/lib/server/session-tools/git.ts +87 -47
  168. package/src/lib/server/session-tools/http.ts +95 -33
  169. package/src/lib/server/session-tools/index.ts +217 -138
  170. package/src/lib/server/session-tools/memory.ts +154 -239
  171. package/src/lib/server/session-tools/monitor.ts +126 -0
  172. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  173. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  174. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  175. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  176. package/src/lib/server/session-tools/platform.ts +86 -0
  177. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  178. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  179. package/src/lib/server/session-tools/sandbox.ts +175 -148
  180. package/src/lib/server/session-tools/schedule.ts +78 -0
  181. package/src/lib/server/session-tools/session-info.ts +104 -410
  182. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  183. package/src/lib/server/session-tools/shell.ts +171 -143
  184. package/src/lib/server/session-tools/subagent.ts +77 -77
  185. package/src/lib/server/session-tools/wallet.ts +182 -106
  186. package/src/lib/server/session-tools/web.ts +181 -327
  187. package/src/lib/server/storage.ts +36 -0
  188. package/src/lib/server/stream-agent-chat.ts +348 -242
  189. package/src/lib/server/task-quality-gate.test.ts +44 -0
  190. package/src/lib/server/task-quality-gate.ts +67 -0
  191. package/src/lib/server/task-validation.test.ts +78 -0
  192. package/src/lib/server/task-validation.ts +67 -2
  193. package/src/lib/server/tool-aliases.ts +68 -0
  194. package/src/lib/server/tool-capability-policy.ts +24 -5
  195. package/src/lib/server/tool-retry.ts +62 -0
  196. package/src/lib/server/transcript-repair.ts +72 -0
  197. package/src/lib/setup-defaults.ts +1 -0
  198. package/src/lib/tasks.ts +7 -1
  199. package/src/lib/tool-definitions.ts +24 -23
  200. package/src/lib/validation/schemas.ts +13 -0
  201. package/src/lib/view-routes.ts +2 -23
  202. package/src/stores/use-app-store.ts +23 -1
  203. package/src/types/index.ts +155 -10
@@ -0,0 +1,208 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { DATA_DIR } from './data-dir'
5
+ import { resolveOpenClawWorkspace } from './openclaw-sync'
6
+ import { loadIntegrityBaselines, saveIntegrityBaselines } from './storage'
7
+
8
+ export interface IntegrityBaselineEntry {
9
+ id: string
10
+ filePath: string
11
+ kind: 'identity' | 'config' | 'plugin'
12
+ present: boolean
13
+ hash: string | null
14
+ size: number | null
15
+ mtimeMs: number | null
16
+ updatedAt: number
17
+ }
18
+
19
+ export interface IntegrityDrift {
20
+ id: string
21
+ filePath: string
22
+ kind: IntegrityBaselineEntry['kind']
23
+ type: 'created' | 'modified' | 'deleted'
24
+ previousHash: string | null
25
+ nextHash: string | null
26
+ checkedAt: number
27
+ }
28
+
29
+ export interface IntegrityMonitorResult {
30
+ enabled: boolean
31
+ checkedAt: number
32
+ checkedFiles: number
33
+ drifts: IntegrityDrift[]
34
+ }
35
+
36
+ interface WatchTarget {
37
+ id: string
38
+ filePath: string
39
+ kind: IntegrityBaselineEntry['kind']
40
+ }
41
+
42
+ function parseBool(value: unknown, fallback: boolean): boolean {
43
+ if (typeof value === 'boolean') return value
44
+ if (typeof value === 'string') {
45
+ const normalized = value.trim().toLowerCase()
46
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
47
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
48
+ }
49
+ return fallback
50
+ }
51
+
52
+ function fileHash(filePath: string): string {
53
+ const hasher = crypto.createHash('sha256')
54
+ const content = fs.readFileSync(filePath)
55
+ hasher.update(content)
56
+ return hasher.digest('hex')
57
+ }
58
+
59
+ function safeStat(filePath: string): fs.Stats | null {
60
+ try {
61
+ return fs.statSync(filePath)
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ function toId(filePath: string): string {
68
+ return crypto.createHash('sha1').update(path.resolve(filePath)).digest('hex')
69
+ }
70
+
71
+ function pushIfExists(targets: WatchTarget[], filePath: string, kind: WatchTarget['kind']): void {
72
+ if (!fs.existsSync(filePath)) return
73
+ targets.push({
74
+ id: toId(filePath),
75
+ filePath: path.resolve(filePath),
76
+ kind,
77
+ })
78
+ }
79
+
80
+ function collectWatchTargets(): WatchTarget[] {
81
+ const targets: WatchTarget[] = []
82
+ const cwd = process.cwd()
83
+
84
+ // Core workspace identity/config files.
85
+ pushIfExists(targets, path.join(cwd, 'AGENTS.md'), 'identity')
86
+ pushIfExists(targets, path.join(cwd, 'SOUL.md'), 'identity')
87
+ pushIfExists(targets, path.join(cwd, 'IDENTITY.md'), 'identity')
88
+ pushIfExists(targets, path.join(cwd, '.env.local'), 'config')
89
+
90
+ // Repo-level AGENTS.md (one level above app dir when present).
91
+ pushIfExists(targets, path.resolve(cwd, '..', 'AGENTS.md'), 'identity')
92
+
93
+ // Plugin files + plugin config.
94
+ pushIfExists(targets, path.join(DATA_DIR, 'plugins.json'), 'config')
95
+ const pluginDir = path.join(DATA_DIR, 'plugins')
96
+ if (fs.existsSync(pluginDir)) {
97
+ for (const entry of fs.readdirSync(pluginDir)) {
98
+ if (!entry.endsWith('.js') && !entry.endsWith('.mjs') && !entry.endsWith('.cjs')) continue
99
+ pushIfExists(targets, path.join(pluginDir, entry), 'plugin')
100
+ }
101
+ }
102
+
103
+ // OpenClaw agent identity files.
104
+ try {
105
+ const workspace = resolveOpenClawWorkspace()
106
+ const agentsDir = path.join(workspace, 'agents')
107
+ if (fs.existsSync(agentsDir)) {
108
+ for (const agentDirName of fs.readdirSync(agentsDir)) {
109
+ const dirPath = path.join(agentsDir, agentDirName)
110
+ if (!safeStat(dirPath)?.isDirectory()) continue
111
+ pushIfExists(targets, path.join(dirPath, 'SOUL.md'), 'identity')
112
+ pushIfExists(targets, path.join(dirPath, 'IDENTITY.md'), 'identity')
113
+ pushIfExists(targets, path.join(dirPath, 'TOOLS.md'), 'identity')
114
+ pushIfExists(targets, path.join(dirPath, 'AGENTS.md'), 'identity')
115
+ }
116
+ }
117
+ } catch {
118
+ // OpenClaw workspace is optional.
119
+ }
120
+
121
+ // Deduplicate path collisions.
122
+ const seen = new Set<string>()
123
+ return targets.filter((target) => {
124
+ if (seen.has(target.id)) return false
125
+ seen.add(target.id)
126
+ return true
127
+ })
128
+ }
129
+
130
+ function toBaseline(target: WatchTarget, checkedAt: number): IntegrityBaselineEntry {
131
+ const stat = safeStat(target.filePath)
132
+ const present = !!stat && stat.isFile()
133
+ return {
134
+ id: target.id,
135
+ filePath: target.filePath,
136
+ kind: target.kind,
137
+ present,
138
+ hash: present ? fileHash(target.filePath) : null,
139
+ size: present ? stat!.size : null,
140
+ mtimeMs: present ? Math.trunc(stat!.mtimeMs) : null,
141
+ updatedAt: checkedAt,
142
+ }
143
+ }
144
+
145
+ export function runIntegrityMonitor(settings?: Record<string, unknown> | null): IntegrityMonitorResult {
146
+ const enabled = parseBool(settings?.integrityMonitorEnabled, true)
147
+ const checkedAt = Date.now()
148
+ if (!enabled) {
149
+ return {
150
+ enabled: false,
151
+ checkedAt,
152
+ checkedFiles: 0,
153
+ drifts: [],
154
+ }
155
+ }
156
+
157
+ const targets = collectWatchTargets()
158
+ const stored = loadIntegrityBaselines() as Record<string, IntegrityBaselineEntry>
159
+ const nextBaselines: Record<string, IntegrityBaselineEntry> = { ...stored }
160
+ const drifts: IntegrityDrift[] = []
161
+ let dirty = false
162
+
163
+ for (const target of targets) {
164
+ const previous = stored[target.id]
165
+ const current = toBaseline(target, checkedAt)
166
+
167
+ if (!previous) {
168
+ nextBaselines[target.id] = current
169
+ dirty = true
170
+ continue
171
+ }
172
+
173
+ const changed = (
174
+ previous.present !== current.present
175
+ || previous.hash !== current.hash
176
+ || previous.filePath !== current.filePath
177
+ || previous.kind !== current.kind
178
+ )
179
+
180
+ if (changed) {
181
+ let type: IntegrityDrift['type'] = 'modified'
182
+ if (!previous.present && current.present) type = 'created'
183
+ else if (previous.present && !current.present) type = 'deleted'
184
+ drifts.push({
185
+ id: current.id,
186
+ filePath: current.filePath,
187
+ kind: current.kind,
188
+ type,
189
+ previousHash: previous.hash || null,
190
+ nextHash: current.hash || null,
191
+ checkedAt,
192
+ })
193
+ nextBaselines[target.id] = current
194
+ dirty = true
195
+ }
196
+ }
197
+
198
+ if (dirty) {
199
+ saveIntegrityBaselines(nextBaselines)
200
+ }
201
+
202
+ return {
203
+ enabled: true,
204
+ checkedAt,
205
+ checkedFiles: targets.length,
206
+ drifts,
207
+ }
208
+ }
@@ -266,6 +266,16 @@ export class SqliteCheckpointSaver extends BaseCheckpointSaver {
266
266
  this.db.prepare(`DELETE FROM langgraph_checkpoints WHERE thread_id = ?`).run(threadId)
267
267
  this.db.prepare(`DELETE FROM langgraph_writes WHERE thread_id = ?`).run(threadId)
268
268
  }
269
+
270
+ async deleteCheckpoint(threadId: string, checkpointId: string): Promise<void> {
271
+ this.db.prepare(`DELETE FROM langgraph_checkpoints WHERE thread_id = ? AND checkpoint_id = ?`).run(threadId, checkpointId)
272
+ this.db.prepare(`DELETE FROM langgraph_writes WHERE thread_id = ? AND checkpoint_id = ?`).run(threadId, checkpointId)
273
+ }
274
+
275
+ async deleteCheckpointsAfter(threadId: string, timestamp: number): Promise<void> {
276
+ this.db.prepare(`DELETE FROM langgraph_checkpoints WHERE thread_id = ? AND created_at > ?`).run(threadId, timestamp)
277
+ this.db.prepare(`DELETE FROM langgraph_writes WHERE thread_id = ? AND checkpoint_id NOT IN (SELECT checkpoint_id FROM langgraph_checkpoints WHERE thread_id = ?)`).run(threadId, threadId)
278
+ }
269
279
  }
270
280
 
271
281
  let _saver: SqliteCheckpointSaver | undefined
@@ -0,0 +1,55 @@
1
+ import * as cheerio from 'cheerio'
2
+ import { truncate } from './session-tools/context'
3
+
4
+ const BARE_LINK_RE = /https?:\/\/\S+/gi
5
+
6
+ /**
7
+ * Automatically fetch and summarize links found in user messages.
8
+ * This aligns SwarmClaw with OpenClaw's proactive link-understanding feature.
9
+ */
10
+ export async function runLinkUnderstanding(message: string): Promise<string[]> {
11
+ const links = message.match(BARE_LINK_RE)
12
+ if (!links || links.length === 0) return []
13
+
14
+ const uniqueLinks = Array.from(new Set(links)).slice(0, 3) // Limit to first 3 links
15
+ const results: string[] = []
16
+
17
+ for (const url of uniqueLinks) {
18
+ try {
19
+ const res = await fetch(url, {
20
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
21
+ signal: AbortSignal.timeout(8000),
22
+ })
23
+ if (!res.ok) continue
24
+
25
+ const contentType = res.headers.get('content-type') || ''
26
+ if (contentType.includes('text/html')) {
27
+ const html = await res.text()
28
+ const $ = cheerio.load(html)
29
+
30
+ // Handle YouTube specifically (OpenClaw favorite)
31
+ if (url.includes('youtube.com/') || url.includes('youtu.be/')) {
32
+ const title = $('meta[property="og:title"]').attr('content') || $('title').text()
33
+ const desc = $('meta[property="og:description"]').attr('content') || ''
34
+ results.push(`[Link Analysis: YouTube] ${url}\nTitle: ${title}\nDescription: ${desc}`)
35
+ continue
36
+ }
37
+
38
+ // General web page extraction
39
+ $('script, style, noscript, nav, footer, header').remove()
40
+ const title = $('title').text().trim()
41
+ const main = $('article, main, [role="main"]').first()
42
+ const bodyText = (main.length ? main.text() : $('body').text())
43
+ .replace(/\s+/g, ' ')
44
+ .trim()
45
+
46
+ results.push(`[Link Analysis] ${url}\nTitle: ${title}\nContent: ${truncate(bodyText, 1000)}`)
47
+ }
48
+ } catch (err) {
49
+ // Fail silently for link understanding — don't block the main run
50
+ console.error(`Link understanding failed for ${url}:`, err)
51
+ }
52
+ }
53
+
54
+ return results
55
+ }
@@ -0,0 +1,102 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import type { Message } from '@/types'
4
+ import {
5
+ buildLlmResponseCacheKey,
6
+ clearLlmResponseCache,
7
+ getCachedLlmResponse,
8
+ resolveLlmResponseCacheConfig,
9
+ setCachedLlmResponse,
10
+ } from './llm-response-cache.ts'
11
+
12
+ const HISTORY: Message[] = [
13
+ { role: 'user', text: 'Plan a release.', time: 1 },
14
+ { role: 'assistant', text: 'Drafted plan.', time: 2 },
15
+ ]
16
+
17
+ test('buildLlmResponseCacheKey is deterministic for equivalent payloads', () => {
18
+ const keyA = buildLlmResponseCacheKey({
19
+ provider: 'openai',
20
+ model: 'gpt-4o-mini',
21
+ apiEndpoint: 'https://api.openai.com/v1',
22
+ systemPrompt: 'System prompt',
23
+ message: 'hello',
24
+ history: HISTORY,
25
+ attachedFiles: ['a.txt', 'b.txt'],
26
+ })
27
+ const keyB = buildLlmResponseCacheKey({
28
+ provider: 'openai',
29
+ model: 'gpt-4o-mini',
30
+ apiEndpoint: 'https://api.openai.com/v1',
31
+ systemPrompt: ' System prompt ',
32
+ message: 'hello',
33
+ history: [...HISTORY],
34
+ attachedFiles: ['a.txt', 'b.txt'],
35
+ })
36
+ assert.equal(keyA, keyB)
37
+ })
38
+
39
+ test('set/get cached responses returns hit and increments hit count', () => {
40
+ clearLlmResponseCache()
41
+ const config = { enabled: true, ttlMs: 60_000, maxEntries: 10 }
42
+ const keyInput = {
43
+ provider: 'openai',
44
+ model: 'gpt-4o',
45
+ message: 'status',
46
+ history: HISTORY,
47
+ }
48
+ setCachedLlmResponse(keyInput, 'cached answer', config, 1000)
49
+ const hit1 = getCachedLlmResponse(keyInput, config, 1500)
50
+ assert.ok(hit1)
51
+ assert.equal(hit1?.text, 'cached answer')
52
+ assert.equal(hit1?.hits, 1)
53
+ const hit2 = getCachedLlmResponse(keyInput, config, 1600)
54
+ assert.equal(hit2?.hits, 2)
55
+ })
56
+
57
+ test('expired cache entry is not returned', () => {
58
+ clearLlmResponseCache()
59
+ const config = { enabled: true, ttlMs: 1000, maxEntries: 10 }
60
+ const keyInput = {
61
+ provider: 'openai',
62
+ model: 'gpt-4o',
63
+ message: 'status',
64
+ history: HISTORY,
65
+ }
66
+ setCachedLlmResponse(keyInput, 'stale', config, 1000)
67
+ const miss = getCachedLlmResponse(keyInput, config, 3001)
68
+ assert.equal(miss, null)
69
+ })
70
+
71
+ test('cache evicts least recently used entries over maxEntries', () => {
72
+ clearLlmResponseCache()
73
+ const config = { enabled: true, ttlMs: 60_000, maxEntries: 2 }
74
+ const inputA = { provider: 'openai', model: 'gpt-4o', message: 'a', history: HISTORY }
75
+ const inputB = { provider: 'openai', model: 'gpt-4o', message: 'b', history: HISTORY }
76
+ const inputC = { provider: 'openai', model: 'gpt-4o', message: 'c', history: HISTORY }
77
+ setCachedLlmResponse(inputA, 'A', config, 1000)
78
+ setCachedLlmResponse(inputB, 'B', config, 1001)
79
+ // Touch A so B becomes LRU.
80
+ getCachedLlmResponse(inputA, config, 1002)
81
+ setCachedLlmResponse(inputC, 'C', config, 1003)
82
+
83
+ assert.equal(getCachedLlmResponse(inputB, config, 1004), null)
84
+ assert.equal(getCachedLlmResponse(inputA, config, 1004)?.text, 'A')
85
+ assert.equal(getCachedLlmResponse(inputC, config, 1004)?.text, 'C')
86
+ })
87
+
88
+ test('resolveLlmResponseCacheConfig applies defaults and bounds', () => {
89
+ const fallback = resolveLlmResponseCacheConfig({})
90
+ assert.equal(fallback.enabled, true)
91
+ assert.equal(fallback.ttlMs, 900_000)
92
+ assert.equal(fallback.maxEntries, 500)
93
+
94
+ const custom = resolveLlmResponseCacheConfig({
95
+ responseCacheEnabled: false,
96
+ responseCacheTtlSec: 1,
97
+ responseCacheMaxEntries: 999999,
98
+ })
99
+ assert.equal(custom.enabled, false)
100
+ assert.equal(custom.ttlMs, 5000)
101
+ assert.equal(custom.maxEntries, 20_000)
102
+ })
@@ -0,0 +1,227 @@
1
+ import crypto from 'node:crypto'
2
+ import type { AppSettings, Message } from '@/types'
3
+
4
+ export interface LlmResponseCacheConfig {
5
+ enabled: boolean
6
+ ttlMs: number
7
+ maxEntries: number
8
+ }
9
+
10
+ export interface LlmResponseCacheKeyInput {
11
+ provider: string
12
+ model: string
13
+ apiEndpoint?: string | null
14
+ systemPrompt?: string
15
+ message: string
16
+ imagePath?: string
17
+ imageUrl?: string
18
+ attachedFiles?: string[]
19
+ history: Message[]
20
+ }
21
+
22
+ export interface LlmResponseCacheHit {
23
+ key: string
24
+ text: string
25
+ provider: string
26
+ model: string
27
+ createdAt: number
28
+ ageMs: number
29
+ hits: number
30
+ }
31
+
32
+ interface LlmResponseCacheEntry {
33
+ key: string
34
+ text: string
35
+ provider: string
36
+ model: string
37
+ createdAt: number
38
+ expiresAt: number
39
+ hits: number
40
+ }
41
+
42
+ const DEFAULT_ENABLED = true
43
+ const DEFAULT_TTL_SEC = 15 * 60
44
+ const DEFAULT_MAX_ENTRIES = 500
45
+
46
+ const MIN_TTL_SEC = 5
47
+ const MAX_TTL_SEC = 7 * 24 * 3600
48
+ const MIN_ENTRIES = 1
49
+ const MAX_ENTRIES = 20_000
50
+
51
+ const responseCache = new Map<string, LlmResponseCacheEntry>()
52
+
53
+ function normalizeText(value: unknown): string {
54
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
55
+ }
56
+
57
+ function normalizeList(value: unknown): string[] {
58
+ if (!Array.isArray(value)) return []
59
+ return value
60
+ .filter((entry): entry is string => typeof entry === 'string')
61
+ .map((entry) => entry.trim())
62
+ .filter(Boolean)
63
+ }
64
+
65
+ function normalizeInt(value: unknown, fallback: number, min: number, max: number): number {
66
+ const parsed = typeof value === 'number'
67
+ ? value
68
+ : typeof value === 'string'
69
+ ? Number.parseInt(value, 10)
70
+ : Number.NaN
71
+ if (!Number.isFinite(parsed)) return fallback
72
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
73
+ }
74
+
75
+ function normalizeBool(value: unknown, fallback: boolean): boolean {
76
+ if (typeof value === 'boolean') return value
77
+ if (typeof value === 'string') {
78
+ const normalized = value.trim().toLowerCase()
79
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
80
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
81
+ }
82
+ return fallback
83
+ }
84
+
85
+ function stableStringify(value: unknown): string {
86
+ if (value === null) return 'null'
87
+ const kind = typeof value
88
+ if (kind === 'number' || kind === 'boolean') return JSON.stringify(value)
89
+ if (kind === 'string') return JSON.stringify(value)
90
+ if (Array.isArray(value)) return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
91
+ if (kind === 'object') {
92
+ const entries = Object.entries(value as Record<string, unknown>)
93
+ .filter(([, v]) => v !== undefined)
94
+ .sort(([a], [b]) => a.localeCompare(b))
95
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`
96
+ }
97
+ return JSON.stringify(String(value))
98
+ }
99
+
100
+ function normalizeHistory(history: Message[]): Array<Record<string, unknown>> {
101
+ return history.map((entry) => ({
102
+ role: entry.role,
103
+ text: normalizeText(entry.text),
104
+ kind: entry.kind || null,
105
+ imagePath: entry.imagePath || null,
106
+ imageUrl: entry.imageUrl || null,
107
+ attachedFiles: normalizeList(entry.attachedFiles),
108
+ replyToId: entry.replyToId || null,
109
+ }))
110
+ }
111
+
112
+ function trimToCapacity(maxEntries: number): void {
113
+ while (responseCache.size > maxEntries) {
114
+ const oldestKey = responseCache.keys().next().value as string | undefined
115
+ if (!oldestKey) break
116
+ responseCache.delete(oldestKey)
117
+ }
118
+ }
119
+
120
+ function moveToMostRecent(key: string, entry: LlmResponseCacheEntry): void {
121
+ responseCache.delete(key)
122
+ responseCache.set(key, entry)
123
+ }
124
+
125
+ export function resolveLlmResponseCacheConfig(
126
+ settings?: AppSettings | Record<string, unknown> | null,
127
+ ): LlmResponseCacheConfig {
128
+ const raw = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {}
129
+ const ttlSec = normalizeInt(raw.responseCacheTtlSec, DEFAULT_TTL_SEC, MIN_TTL_SEC, MAX_TTL_SEC)
130
+ const maxEntries = normalizeInt(raw.responseCacheMaxEntries, DEFAULT_MAX_ENTRIES, MIN_ENTRIES, MAX_ENTRIES)
131
+ const enabled = normalizeBool(raw.responseCacheEnabled, DEFAULT_ENABLED)
132
+ return {
133
+ enabled,
134
+ ttlMs: ttlSec * 1000,
135
+ maxEntries,
136
+ }
137
+ }
138
+
139
+ export function buildLlmResponseCacheKey(input: LlmResponseCacheKeyInput): string {
140
+ const payload = {
141
+ provider: normalizeText(input.provider).toLowerCase(),
142
+ model: normalizeText(input.model),
143
+ apiEndpoint: normalizeText(input.apiEndpoint || ''),
144
+ systemPrompt: normalizeText(input.systemPrompt || ''),
145
+ message: normalizeText(input.message),
146
+ imagePath: normalizeText(input.imagePath || ''),
147
+ imageUrl: normalizeText(input.imageUrl || ''),
148
+ attachedFiles: normalizeList(input.attachedFiles),
149
+ history: normalizeHistory(Array.isArray(input.history) ? input.history : []),
150
+ }
151
+ const stable = stableStringify(payload)
152
+ return crypto.createHash('sha256').update(stable).digest('hex')
153
+ }
154
+
155
+ export function getCachedLlmResponse(
156
+ input: LlmResponseCacheKeyInput,
157
+ config: LlmResponseCacheConfig,
158
+ now = Date.now(),
159
+ ): LlmResponseCacheHit | null {
160
+ if (!config.enabled) return null
161
+ const key = buildLlmResponseCacheKey(input)
162
+ const found = responseCache.get(key)
163
+ if (!found) return null
164
+ if (now >= found.expiresAt) {
165
+ responseCache.delete(key)
166
+ return null
167
+ }
168
+ const next = { ...found, hits: found.hits + 1 }
169
+ moveToMostRecent(key, next)
170
+ return {
171
+ key,
172
+ text: next.text,
173
+ provider: next.provider,
174
+ model: next.model,
175
+ createdAt: next.createdAt,
176
+ ageMs: Math.max(0, now - next.createdAt),
177
+ hits: next.hits,
178
+ }
179
+ }
180
+
181
+ export function setCachedLlmResponse(
182
+ input: LlmResponseCacheKeyInput,
183
+ text: string,
184
+ config: LlmResponseCacheConfig,
185
+ now = Date.now(),
186
+ ): void {
187
+ if (!config.enabled) return
188
+ const normalizedText = normalizeText(text)
189
+ if (!normalizedText) return
190
+ const key = buildLlmResponseCacheKey(input)
191
+ const existing = responseCache.get(key)
192
+ const createdAt = existing?.createdAt ?? now
193
+ const entry: LlmResponseCacheEntry = {
194
+ key,
195
+ text: normalizedText,
196
+ provider: normalizeText(input.provider).toLowerCase(),
197
+ model: normalizeText(input.model),
198
+ createdAt,
199
+ expiresAt: now + config.ttlMs,
200
+ hits: existing?.hits ?? 0,
201
+ }
202
+ moveToMostRecent(key, entry)
203
+ trimToCapacity(config.maxEntries)
204
+ }
205
+
206
+ export function getLlmResponseCacheStats(now = Date.now()): {
207
+ entries: number
208
+ expired: number
209
+ oldestAgeMs: number
210
+ } {
211
+ let expired = 0
212
+ let oldestCreatedAt = Number.POSITIVE_INFINITY
213
+ for (const entry of responseCache.values()) {
214
+ if (entry.expiresAt <= now) expired++
215
+ oldestCreatedAt = Math.min(oldestCreatedAt, entry.createdAt)
216
+ }
217
+ const oldestAgeMs = Number.isFinite(oldestCreatedAt) ? Math.max(0, now - oldestCreatedAt) : 0
218
+ return {
219
+ entries: responseCache.size,
220
+ expired,
221
+ oldestAgeMs,
222
+ }
223
+ }
224
+
225
+ export function clearLlmResponseCache(): void {
226
+ responseCache.clear()
227
+ }