claude-brain 0.22.4 → 0.24.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.
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/postinstall.mjs +81 -3
- package/src/cli/auto-start.ts +266 -0
- package/src/cli/bin.ts +7 -0
- package/src/cli/commands/autostart.ts +90 -0
- package/src/cli/commands/install-mcp.ts +4 -4
- package/src/cli/commands/refresh.ts +1 -1
- package/src/cli/commands/serve.ts +56 -0
- package/src/cli/commands/uninstall-mcp.ts +2 -2
- package/src/config/schema.ts +10 -0
- package/src/hooks/passive-classifier.ts +1 -1
- package/src/memory/fts5-search.ts +149 -0
- package/src/memory/index.ts +162 -41
- package/src/memory/migrations/add-fts5.ts +10 -0
- package/src/routing/intent-classifier.ts +7 -1
- package/src/server/auto-updater.ts +301 -0
- package/src/server/http-api.ts +11 -2
- package/src/server/pid-manager.ts +64 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-updater for claude-brain.
|
|
3
|
+
* Checks npm for new versions periodically, auto-updates with ghost process cleanup and fresh restart.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execSync, spawn } from 'node:child_process'
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
8
|
+
import { join, dirname, resolve } from 'node:path'
|
|
9
|
+
import { homedir, platform } from 'node:os'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
13
|
+
const __dirname = dirname(__filename)
|
|
14
|
+
const PACKAGE_ROOT = resolve(__dirname, '..', '..')
|
|
15
|
+
|
|
16
|
+
interface UpdateCheckResult {
|
|
17
|
+
currentVersion: string
|
|
18
|
+
latestVersion: string | null
|
|
19
|
+
updateAvailable: boolean
|
|
20
|
+
lastChecked: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AutoUpdateConfig {
|
|
24
|
+
enabled: boolean
|
|
25
|
+
checkIntervalHours: number
|
|
26
|
+
autoRestart: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CONFIG: AutoUpdateConfig = {
|
|
30
|
+
enabled: true,
|
|
31
|
+
checkIntervalHours: 24,
|
|
32
|
+
autoRestart: true,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isWindows = platform() === 'win32'
|
|
36
|
+
|
|
37
|
+
export class AutoUpdater {
|
|
38
|
+
private dataDir: string
|
|
39
|
+
private checkFile: string
|
|
40
|
+
private config: AutoUpdateConfig
|
|
41
|
+
private timer: ReturnType<typeof setInterval> | null = null
|
|
42
|
+
private logger: { info: (...args: any[]) => void; warn: (...args: any[]) => void; error: (...args: any[]) => void }
|
|
43
|
+
|
|
44
|
+
constructor(config?: Partial<AutoUpdateConfig>, logger?: any) {
|
|
45
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
46
|
+
this.dataDir = join(homedir(), '.claude-brain', 'data')
|
|
47
|
+
this.checkFile = join(this.dataDir, 'update-check.json')
|
|
48
|
+
this.logger = logger ?? {
|
|
49
|
+
info: (...args: any[]) => console.error('[auto-updater] INFO:', ...args),
|
|
50
|
+
warn: (...args: any[]) => console.error('[auto-updater] WARN:', ...args),
|
|
51
|
+
error: (...args: any[]) => console.error('[auto-updater] ERROR:', ...args),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Check npm registry for latest version */
|
|
56
|
+
async check(): Promise<UpdateCheckResult> {
|
|
57
|
+
const currentVersion = this.getCurrentVersion()
|
|
58
|
+
|
|
59
|
+
// Return cached result if checked recently
|
|
60
|
+
if (!this.shouldCheck()) {
|
|
61
|
+
try {
|
|
62
|
+
const cached = JSON.parse(readFileSync(this.checkFile, 'utf-8')) as UpdateCheckResult
|
|
63
|
+
return { ...cached, currentVersion }
|
|
64
|
+
} catch {
|
|
65
|
+
// Corrupted cache, proceed with fresh check
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let latestVersion: string | null = null
|
|
70
|
+
try {
|
|
71
|
+
const result = execSync('npm view claude-brain version', {
|
|
72
|
+
encoding: 'utf-8',
|
|
73
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
74
|
+
timeout: 15_000,
|
|
75
|
+
})
|
|
76
|
+
latestVersion = result.trim()
|
|
77
|
+
} catch {
|
|
78
|
+
this.logger.warn('Failed to check npm registry for latest version')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const updateAvailable = latestVersion !== null && latestVersion !== currentVersion
|
|
82
|
+
const checkResult: UpdateCheckResult = {
|
|
83
|
+
currentVersion,
|
|
84
|
+
latestVersion,
|
|
85
|
+
updateAvailable,
|
|
86
|
+
lastChecked: new Date().toISOString(),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Persist check result
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(this.dataDir, { recursive: true })
|
|
92
|
+
writeFileSync(this.checkFile, JSON.stringify(checkResult, null, 2), 'utf-8')
|
|
93
|
+
} catch (err) {
|
|
94
|
+
this.logger.warn('Failed to write update check file:', err)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (updateAvailable) {
|
|
98
|
+
this.logger.info(`Update available: v${currentVersion} -> v${latestVersion}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return checkResult
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Perform the update: kill ghosts -> update package -> restart */
|
|
105
|
+
async performUpdate(targetVersion: string): Promise<boolean> {
|
|
106
|
+
try {
|
|
107
|
+
this.logger.info(`Updating claude-brain to v${targetVersion}...`)
|
|
108
|
+
|
|
109
|
+
// Kill ghost processes
|
|
110
|
+
this.killGhostProcesses()
|
|
111
|
+
|
|
112
|
+
// Wait for ports to release
|
|
113
|
+
await this.sleep(2000)
|
|
114
|
+
|
|
115
|
+
// Try bun first, fallback to npm
|
|
116
|
+
try {
|
|
117
|
+
execSync('bun install -g claude-brain@latest', {
|
|
118
|
+
encoding: 'utf-8',
|
|
119
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
120
|
+
timeout: 120_000,
|
|
121
|
+
})
|
|
122
|
+
this.logger.info(`Updated to v${targetVersion} via bun`)
|
|
123
|
+
} catch {
|
|
124
|
+
try {
|
|
125
|
+
execSync('npm install -g claude-brain@latest', {
|
|
126
|
+
encoding: 'utf-8',
|
|
127
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
128
|
+
timeout: 120_000,
|
|
129
|
+
})
|
|
130
|
+
this.logger.info(`Updated to v${targetVersion} via npm`)
|
|
131
|
+
} catch (err) {
|
|
132
|
+
this.logger.error('Install failed with both bun and npm:', err)
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (this.config.autoRestart) {
|
|
138
|
+
this.restartServer()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return true
|
|
142
|
+
} catch (err) {
|
|
143
|
+
this.logger.error('Update failed:', err)
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Schedule periodic background checks */
|
|
149
|
+
schedulePeriodicCheck(): void {
|
|
150
|
+
if (!this.config.enabled) return
|
|
151
|
+
if (this.timer) return
|
|
152
|
+
|
|
153
|
+
const intervalMs = this.config.checkIntervalHours * 3600 * 1000
|
|
154
|
+
|
|
155
|
+
this.timer = setInterval(async () => {
|
|
156
|
+
try {
|
|
157
|
+
const result = await this.check()
|
|
158
|
+
if (result.updateAvailable && result.latestVersion && this.config.enabled) {
|
|
159
|
+
await this.performUpdate(result.latestVersion)
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
this.logger.error('Periodic update check failed:', err)
|
|
163
|
+
}
|
|
164
|
+
}, intervalMs)
|
|
165
|
+
|
|
166
|
+
// Don't keep the process alive just for the update timer
|
|
167
|
+
if (this.timer && typeof this.timer === 'object' && 'unref' in this.timer) {
|
|
168
|
+
this.timer.unref()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.logger.info(`Scheduled update checks every ${this.config.checkIntervalHours}h`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Stop periodic checks */
|
|
175
|
+
stopPeriodicCheck(): void {
|
|
176
|
+
if (this.timer) {
|
|
177
|
+
clearInterval(this.timer)
|
|
178
|
+
this.timer = null
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Check if enough time has passed since last check */
|
|
183
|
+
private shouldCheck(): boolean {
|
|
184
|
+
if (!existsSync(this.checkFile)) return true
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const data = JSON.parse(readFileSync(this.checkFile, 'utf-8')) as UpdateCheckResult
|
|
188
|
+
if (!data.lastChecked) return true
|
|
189
|
+
|
|
190
|
+
const lastChecked = new Date(data.lastChecked).getTime()
|
|
191
|
+
const intervalMs = this.config.checkIntervalHours * 3600 * 1000
|
|
192
|
+
return Date.now() - lastChecked >= intervalMs
|
|
193
|
+
} catch {
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Kill all ghost claude-brain processes */
|
|
199
|
+
private killGhostProcesses(): void {
|
|
200
|
+
const myPid = process.pid
|
|
201
|
+
|
|
202
|
+
// Kill by pattern
|
|
203
|
+
try {
|
|
204
|
+
if (isWindows) {
|
|
205
|
+
const result = execSync(
|
|
206
|
+
`wmic process where "commandline like '%claude-brain%' and processid != ${myPid}" get processid /format:list`,
|
|
207
|
+
{ encoding: 'utf-8', stdio: 'pipe', timeout: 5000 }
|
|
208
|
+
)
|
|
209
|
+
const pids = result.match(/ProcessId=(\d+)/g)?.map(m => m.split('=')[1]) || []
|
|
210
|
+
for (const pid of pids) {
|
|
211
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
const result = execSync(`pgrep -f "claude-brain"`, {
|
|
215
|
+
encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
|
|
216
|
+
})
|
|
217
|
+
const pids = result.trim().split('\n').filter(p => p && Number(p) !== myPid)
|
|
218
|
+
for (const pid of pids) {
|
|
219
|
+
try { process.kill(Number(pid), 'SIGKILL') } catch {}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// No matching processes — that's fine
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Kill by port 3000
|
|
227
|
+
try {
|
|
228
|
+
if (isWindows) {
|
|
229
|
+
const result = execSync(`netstat -ano | findstr :3000 | findstr LISTENING`, {
|
|
230
|
+
encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
|
|
231
|
+
})
|
|
232
|
+
const pids = new Set(
|
|
233
|
+
result.split('\n')
|
|
234
|
+
.map(line => line.trim().split(/\s+/).pop())
|
|
235
|
+
.filter(p => p && Number(p) !== myPid)
|
|
236
|
+
)
|
|
237
|
+
for (const pid of pids) {
|
|
238
|
+
try { execSync(`taskkill /F /PID ${pid}`, { stdio: 'pipe', timeout: 5000 }) } catch {}
|
|
239
|
+
}
|
|
240
|
+
} else {
|
|
241
|
+
const raw = execSync(`lsof -ti :3000`, {
|
|
242
|
+
encoding: 'utf-8', stdio: 'pipe', timeout: 5000,
|
|
243
|
+
}).trim()
|
|
244
|
+
if (raw) {
|
|
245
|
+
const pids = raw.split('\n').filter(p => p && Number(p) !== myPid)
|
|
246
|
+
for (const pid of pids) {
|
|
247
|
+
try { process.kill(Number(pid), 'SIGKILL') } catch {}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch {
|
|
252
|
+
// No process on port — that's fine
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Clean up stale PID files
|
|
256
|
+
const pidPath = join(this.dataDir, 'server.pid')
|
|
257
|
+
if (existsSync(pidPath)) {
|
|
258
|
+
try {
|
|
259
|
+
const pid = parseInt(readFileSync(pidPath, 'utf-8').trim(), 10)
|
|
260
|
+
if (!isNaN(pid) && pid !== myPid) {
|
|
261
|
+
try { process.kill(pid, 0) } catch {
|
|
262
|
+
// Process not running, remove stale PID file
|
|
263
|
+
try { unlinkSync(pidPath) } catch {}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Spawn a fresh server instance and exit current */
|
|
271
|
+
private restartServer(): void {
|
|
272
|
+
this.logger.info('Restarting server with updated version...')
|
|
273
|
+
|
|
274
|
+
const child = spawn('claude-brain', ['serve'], {
|
|
275
|
+
detached: true,
|
|
276
|
+
stdio: 'ignore',
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
child.unref()
|
|
280
|
+
|
|
281
|
+
this.logger.info(`Spawned new server process (PID: ${child.pid}). Exiting current process.`)
|
|
282
|
+
process.exit(0)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Get current installed version from package.json */
|
|
286
|
+
private getCurrentVersion(): string {
|
|
287
|
+
try {
|
|
288
|
+
// Use the version from the package that's actually running
|
|
289
|
+
const pkgPath = join(PACKAGE_ROOT, 'package.json')
|
|
290
|
+
if (existsSync(pkgPath)) {
|
|
291
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
|
|
292
|
+
return pkg.version || 'unknown'
|
|
293
|
+
}
|
|
294
|
+
} catch {}
|
|
295
|
+
return 'unknown'
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private sleep(ms: number): Promise<void> {
|
|
299
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
300
|
+
}
|
|
301
|
+
}
|
package/src/server/http-api.ts
CHANGED
|
@@ -1065,8 +1065,17 @@ export class HttpApiServer {
|
|
|
1065
1065
|
port,
|
|
1066
1066
|
url: `http://localhost:${port}`,
|
|
1067
1067
|
}, 'HTTP API server started successfully')
|
|
1068
|
-
} catch (error) {
|
|
1069
|
-
|
|
1068
|
+
} catch (error: any) {
|
|
1069
|
+
const code = error?.code || error?.message || ''
|
|
1070
|
+
if (String(code).includes('EADDRINUSE') || String(error?.message).includes('EADDRINUSE')) {
|
|
1071
|
+
this.logger.error(
|
|
1072
|
+
{ port },
|
|
1073
|
+
`Port ${port} is already in use. Another claude-brain instance may be running. ` +
|
|
1074
|
+
`Kill existing instances with: pkill -f "claude-brain serve"`
|
|
1075
|
+
)
|
|
1076
|
+
} else {
|
|
1077
|
+
this.logger.error({ error }, 'Failed to start HTTP API server')
|
|
1078
|
+
}
|
|
1070
1079
|
throw error
|
|
1071
1080
|
}
|
|
1072
1081
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PID file manager for the Claude Brain server.
|
|
3
|
+
* Ensures only one server instance runs at a time.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { getHomePaths } from '@/config/home'
|
|
9
|
+
|
|
10
|
+
const PID_FILENAME = 'server.pid'
|
|
11
|
+
|
|
12
|
+
export class ServerPidManager {
|
|
13
|
+
private pidFilePath: string
|
|
14
|
+
|
|
15
|
+
constructor() {
|
|
16
|
+
const paths = getHomePaths()
|
|
17
|
+
this.pidFilePath = join(paths.data, PID_FILENAME)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Check if another server instance is already running. Returns the PID if alive, null otherwise. */
|
|
21
|
+
getRunningPid(): number | null {
|
|
22
|
+
if (!existsSync(this.pidFilePath)) return null
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const pid = parseInt(readFileSync(this.pidFilePath, 'utf-8').trim(), 10)
|
|
26
|
+
if (isNaN(pid)) {
|
|
27
|
+
this.cleanup()
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
// Signal 0 tests if process exists without killing it
|
|
31
|
+
process.kill(pid, 0)
|
|
32
|
+
return pid
|
|
33
|
+
} catch {
|
|
34
|
+
// Process not running, clean up stale PID file
|
|
35
|
+
this.cleanup()
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Write the current process PID to the PID file. */
|
|
41
|
+
writePidFile(): void {
|
|
42
|
+
writeFileSync(this.pidFilePath, String(process.pid), 'utf-8')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Remove the PID file. Safe to call multiple times. */
|
|
46
|
+
cleanup(): void {
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(this.pidFilePath)
|
|
49
|
+
} catch {
|
|
50
|
+
// Already removed or doesn't exist
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Register cleanup handlers on SIGINT, SIGTERM, and process exit. */
|
|
55
|
+
registerCleanupHandlers(): void {
|
|
56
|
+
const doCleanup = () => {
|
|
57
|
+
this.cleanup()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
process.on('exit', doCleanup)
|
|
61
|
+
process.on('SIGINT', doCleanup)
|
|
62
|
+
process.on('SIGTERM', doCleanup)
|
|
63
|
+
}
|
|
64
|
+
}
|