kira-glasses-bridge 2.9.0 → 2.11.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/package.json +2 -2
- package/src/agents/claude.mjs +3 -0
- package/src/claude-sessions.mjs +65 -104
- package/src/index.mjs +63 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kira-glasses-bridge",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.0",
|
|
4
4
|
"description": "Connects your Mac to Kira, the assistant for Even G2 glasses: Claude Code sessions, files, folders and apps, by voice.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -28,5 +28,5 @@
|
|
|
28
28
|
"kira"
|
|
29
29
|
],
|
|
30
30
|
"license": "MIT",
|
|
31
|
-
"homepage": "https://
|
|
31
|
+
"homepage": "https://kira.glass"
|
|
32
32
|
}
|
package/src/agents/claude.mjs
CHANGED
|
@@ -202,6 +202,9 @@ export async function run({ prompt, sessionId, cwd, policy, persona, abort }, io
|
|
|
202
202
|
// A team member's soul, skills and conventions ride on top of Claude Code's own prompt; its preset says which tools it does without.
|
|
203
203
|
...(persona?.systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: persona.systemPrompt } } : {}),
|
|
204
204
|
...(persona?.disallowedTools?.length ? { disallowedTools: persona.disallowedTools } : {}),
|
|
205
|
+
// Kira has its own Gmail/Calendar/Drive tools; keep the user's claude.ai connectors out of
|
|
206
|
+
// the sessions it runs, so there's one pathway for acting on their accounts (Kira's).
|
|
207
|
+
env: { ...process.env, ENABLE_CLAUDEAI_MCP_SERVERS: 'false' },
|
|
205
208
|
canUseTool,
|
|
206
209
|
hooks: { PreToolUse: [{ hooks: [preToolUse] }] },
|
|
207
210
|
...(process.env.KIRA_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.KIRA_CLAUDE_BIN } : {}),
|
package/src/claude-sessions.mjs
CHANGED
|
@@ -1,42 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import {
|
|
1
|
+
// Claude Code's sessions on this Mac, read through the Agent SDK (listSessions, getSessionInfo,
|
|
2
|
+
// getSessionMessages) rather than by parsing ~/.claude/projects ourselves: the transcript format
|
|
3
|
+
// is Claude Code's own and changes between releases, and the SDK changes with it.
|
|
4
|
+
import { getSessionInfo, getSessionMessages, listSessions as sdkListSessions } from '@anthropic-ai/claude-agent-sdk'
|
|
5
|
+
import { open, readdir, stat } from 'node:fs/promises'
|
|
5
6
|
import { homedir } from 'node:os'
|
|
6
7
|
import { basename, join } from 'node:path'
|
|
7
8
|
|
|
8
|
-
const PROJECTS = join(homedir(), '.claude', 'projects')
|
|
9
9
|
const ACTIVE_WINDOW_MS = 3 * 60 * 1000
|
|
10
10
|
|
|
11
|
-
async function sessionFiles() {
|
|
12
|
-
const out = []
|
|
13
|
-
let dirs = []
|
|
14
|
-
try {
|
|
15
|
-
dirs = await readdir(PROJECTS)
|
|
16
|
-
} catch {
|
|
17
|
-
return out
|
|
18
|
-
}
|
|
19
|
-
for (const d of dirs) {
|
|
20
|
-
let files = []
|
|
21
|
-
try {
|
|
22
|
-
files = await readdir(join(PROJECTS, d))
|
|
23
|
-
} catch {
|
|
24
|
-
continue
|
|
25
|
-
}
|
|
26
|
-
for (const f of files) {
|
|
27
|
-
if (!f.endsWith('.jsonl')) continue
|
|
28
|
-
const path = join(PROJECTS, d, f)
|
|
29
|
-
try {
|
|
30
|
-
const st = await stat(path)
|
|
31
|
-
if (st.size > 0) out.push({ path, mtime: st.mtimeMs, size: st.size })
|
|
32
|
-
} catch {
|
|
33
|
-
/* skip */
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
return out.sort((a, b) => b.mtime - a.mtime)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
11
|
function textOf(message) {
|
|
41
12
|
const c = message?.content
|
|
42
13
|
if (typeof c === 'string') return c
|
|
@@ -44,52 +15,60 @@ function textOf(message) {
|
|
|
44
15
|
return ''
|
|
45
16
|
}
|
|
46
17
|
|
|
47
|
-
/**
|
|
48
|
-
async function
|
|
49
|
-
const
|
|
50
|
-
const buf = await readFile(file.path)
|
|
51
|
-
const slice = buf.length > tailBytes ? buf.subarray(buf.length - tailBytes) : buf
|
|
52
|
-
const lines = slice.toString('utf8').split('\n')
|
|
53
|
-
if (buf.length > tailBytes) lines.shift() // drop a partial first line
|
|
54
|
-
let sessionId = file.path.split('/').pop().replace(/\.jsonl$/, '')
|
|
55
|
-
let cwd = null
|
|
56
|
-
let title = null
|
|
18
|
+
/** The conversation as said: user and Claude text, without tool results, system blocks or subagents. */
|
|
19
|
+
async function turnsOf(sessionId, dir) {
|
|
20
|
+
const messages = await getSessionMessages(sessionId, dir ? { dir } : undefined)
|
|
57
21
|
const turns = []
|
|
58
|
-
for (const
|
|
59
|
-
if (
|
|
60
|
-
|
|
22
|
+
for (const m of messages) {
|
|
23
|
+
if (m.parent_tool_use_id) continue
|
|
24
|
+
const text = textOf(m.message).trim()
|
|
25
|
+
if (!text || text.startsWith('<')) continue // tool results / system blocks
|
|
26
|
+
turns.push({ role: m.type === 'user' ? 'user' : 'claude', text, at: m.timestamp ?? null })
|
|
27
|
+
}
|
|
28
|
+
return turns
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The SDK reads a session's folder from the start of its transcript, and misses it when the
|
|
33
|
+
* transcript opens with large snapshot entries. Then: the first "cwd" in the first MB of the file.
|
|
34
|
+
*/
|
|
35
|
+
async function cwdOf(s) {
|
|
36
|
+
if (s.cwd) return s.cwd
|
|
37
|
+
const projects = join(homedir(), '.claude', 'projects')
|
|
38
|
+
for (const d of await readdir(projects).catch(() => [])) {
|
|
39
|
+
const fh = await open(join(projects, d, `${s.sessionId}.jsonl`)).catch(() => null)
|
|
40
|
+
if (!fh) continue
|
|
61
41
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (d.type === 'ai-title' && typeof d.aiTitle === 'string') title = d.aiTitle
|
|
69
|
-
if ((d.type === 'user' || d.type === 'assistant') && d.message) {
|
|
70
|
-
const text = textOf(d.message).trim()
|
|
71
|
-
if (!text || text.startsWith('<') ) continue // skip tool results / system blocks
|
|
72
|
-
turns.push({ role: d.type === 'user' ? 'user' : 'claude', text, at: d.timestamp ?? null })
|
|
42
|
+
const buf = Buffer.alloc(1 << 20)
|
|
43
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, 0)
|
|
44
|
+
const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(buf.subarray(0, bytesRead).toString('utf8'))
|
|
45
|
+
return m ? JSON.parse(`"${m[1]}"`) : null
|
|
46
|
+
} finally {
|
|
47
|
+
await fh.close()
|
|
73
48
|
}
|
|
74
49
|
}
|
|
75
|
-
return
|
|
50
|
+
return null
|
|
76
51
|
}
|
|
77
52
|
|
|
53
|
+
const titleOf = s => s.customTitle || s.summary || s.firstPrompt || null
|
|
54
|
+
const activeOf = s => Date.now() - s.lastModified < ACTIVE_WINDOW_MS
|
|
55
|
+
|
|
78
56
|
export async function listSessions(max = 10) {
|
|
79
|
-
const
|
|
57
|
+
const n = Math.min(Math.max(Number(max) || 10, 1), 20)
|
|
80
58
|
const sessions = []
|
|
81
|
-
for (const
|
|
59
|
+
for (const s of await sdkListSessions({ limit: n })) {
|
|
82
60
|
try {
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
61
|
+
const cwd = await cwdOf(s)
|
|
62
|
+
const turns = await turnsOf(s.sessionId, cwd)
|
|
63
|
+
const lastUser = [...turns].reverse().find(t => t.role === 'user')
|
|
64
|
+
const lastClaude = [...turns].reverse().find(t => t.role === 'claude')
|
|
86
65
|
sessions.push({
|
|
87
66
|
session_id: s.sessionId,
|
|
88
|
-
title: s
|
|
89
|
-
project:
|
|
90
|
-
cwd
|
|
91
|
-
active: s
|
|
92
|
-
last_active_at: s.
|
|
67
|
+
title: titleOf(s) ?? (lastUser ? lastUser.text.slice(0, 60) : '(untitled)'),
|
|
68
|
+
project: cwd ? cwd.split('/').slice(-2).join('/') : null,
|
|
69
|
+
cwd,
|
|
70
|
+
active: activeOf(s),
|
|
71
|
+
last_active_at: new Date(s.lastModified).toISOString(),
|
|
93
72
|
last_user_message: lastUser ? lastUser.text.slice(0, 200) : null,
|
|
94
73
|
last_claude_message: lastClaude ? lastClaude.text.slice(0, 200) : null,
|
|
95
74
|
})
|
|
@@ -101,49 +80,31 @@ export async function listSessions(max = 10) {
|
|
|
101
80
|
}
|
|
102
81
|
|
|
103
82
|
export async function readSession(sessionId, turns = 6) {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
83
|
+
const info = await getSessionInfo(sessionId).catch(() => undefined)
|
|
84
|
+
if (!info) return { error: `No session ${sessionId} on this machine` }
|
|
85
|
+
const cwd = await cwdOf(info)
|
|
86
|
+
const all = await turnsOf(sessionId, cwd)
|
|
108
87
|
const n = Math.min(Math.max(Number(turns) || 6, 1), 30)
|
|
109
|
-
return { session_id:
|
|
88
|
+
return { session_id: info.sessionId, title: titleOf(info), cwd, active: activeOf(info), last_active_at: new Date(info.lastModified).toISOString(), turns: all.slice(-n).map(t => ({ ...t, text: t.text.slice(0, 2000) })) }
|
|
110
89
|
}
|
|
111
90
|
|
|
112
|
-
/**
|
|
113
|
-
* Project folders Claude Code has been used in, newest first. One cheap read
|
|
114
|
-
* per project: the head of its newest transcript carries the cwd.
|
|
115
|
-
*/
|
|
91
|
+
/** Project folders Claude Code has been used in (that still exist), newest first. */
|
|
116
92
|
export async function listProjects(max = 20) {
|
|
117
93
|
const n = Math.min(Math.max(Number(max) || 20, 1), 50)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
94
|
+
const byCwd = new Map()
|
|
95
|
+
for (const s of await sdkListSessions()) {
|
|
96
|
+
if (!s.cwd) continue
|
|
97
|
+
const p = byCwd.get(s.cwd) ?? { sessions: 0, last: 0 }
|
|
98
|
+
p.sessions++
|
|
99
|
+
p.last = Math.max(p.last, s.lastModified)
|
|
100
|
+
byCwd.set(s.cwd, p)
|
|
123
101
|
}
|
|
124
102
|
const projects = []
|
|
125
|
-
for (const
|
|
126
|
-
|
|
127
|
-
let files = []
|
|
128
|
-
try {
|
|
129
|
-
files = (await readdir(dir)).filter(f => f.endsWith('.jsonl'))
|
|
130
|
-
} catch {
|
|
131
|
-
continue
|
|
132
|
-
}
|
|
133
|
-
let newest = null
|
|
134
|
-
for (const f of files) {
|
|
135
|
-
const st = await stat(join(dir, f)).catch(() => null)
|
|
136
|
-
if (st && st.size > 0 && (!newest || st.mtimeMs > newest.mtime)) newest = { path: join(dir, f), mtime: st.mtimeMs }
|
|
137
|
-
}
|
|
138
|
-
if (!newest) continue
|
|
139
|
-
const head = (await readFile(newest.path)).subarray(0, 65536).toString('utf8')
|
|
140
|
-
const m = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(head)
|
|
141
|
-
if (!m) continue
|
|
142
|
-
const cwd = JSON.parse(`"${m[1]}"`)
|
|
103
|
+
for (const [cwd, p] of [...byCwd].sort((a, b) => b[1].last - a[1].last)) {
|
|
104
|
+
if (projects.length >= n) break
|
|
143
105
|
const exists = await stat(cwd).then(st => st.isDirectory()).catch(() => false)
|
|
144
106
|
if (!exists) continue
|
|
145
|
-
projects.push({ cwd, name: basename(cwd), parent: basename(join(cwd, '..')), sessions:
|
|
107
|
+
projects.push({ cwd, name: basename(cwd), parent: basename(join(cwd, '..')), sessions: p.sessions, last_active_at: new Date(p.last).toISOString() })
|
|
146
108
|
}
|
|
147
|
-
|
|
148
|
-
return { count: Math.min(projects.length, n), projects: projects.slice(0, n).map(({ _mtime, ...p }) => p) }
|
|
109
|
+
return { count: projects.length, projects }
|
|
149
110
|
}
|
package/src/index.mjs
CHANGED
|
@@ -35,19 +35,50 @@ function sendLive(obj) {
|
|
|
35
35
|
}
|
|
36
36
|
const ANSWER_TIMEOUT_MS = 3 * 60 * 1000
|
|
37
37
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* How the host hears from the bridge. The CLI passes nothing (logs to stdout, exits when the
|
|
40
|
+
* token is rejected); an app embedding it (the Kira desktop app) passes its own:
|
|
41
|
+
* log(line) every log line
|
|
42
|
+
* onStatus(status) 'connecting' | 'connected' | 'disconnected' | 'rejected'
|
|
43
|
+
*/
|
|
44
|
+
let hooks = {}
|
|
45
|
+
let stopped = false
|
|
46
|
+
let reconnectTimer = null
|
|
47
|
+
|
|
48
|
+
/** Start the bridge with resolved settings. Runs until stopBridge() or the process exits. */
|
|
49
|
+
export function startBridge(config, hostHooks = {}) {
|
|
50
|
+
hooks = hostHooks
|
|
40
51
|
URL_ = config.url
|
|
41
52
|
TOKEN = config.token
|
|
42
53
|
if (config.claudeBin) process.env.KIRA_CLAUDE_BIN = config.claudeBin
|
|
43
54
|
if (!URL_ || !TOKEN) {
|
|
44
|
-
|
|
45
|
-
process.exit(1)
|
|
55
|
+
throw new Error('No token. Run: npx kira-glasses-bridge --token <token> (from the Kira portal → Connections → Claude Code)')
|
|
46
56
|
}
|
|
57
|
+
stopped = false
|
|
47
58
|
log(`kira-bridge ${BRIDGE_VERSION} · agents: ${Object.keys(AGENTS).join(', ')}`)
|
|
48
59
|
connect()
|
|
49
60
|
}
|
|
50
61
|
|
|
62
|
+
/** Disconnect and stop reconnecting (the app calls this on sign-out or a token change). */
|
|
63
|
+
export function stopBridge() {
|
|
64
|
+
stopped = true
|
|
65
|
+
clearTimeout(reconnectTimer)
|
|
66
|
+
const ws = live
|
|
67
|
+
live = null
|
|
68
|
+
try {
|
|
69
|
+
ws?.close(1000, 'stopped')
|
|
70
|
+
} catch {
|
|
71
|
+
/* already closed */
|
|
72
|
+
}
|
|
73
|
+
status('disconnected')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const bridgeVersion = BRIDGE_VERSION
|
|
77
|
+
|
|
78
|
+
function status(s) {
|
|
79
|
+
hooks.onStatus?.(s)
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
const tools = {
|
|
52
83
|
async list_files(args) {
|
|
53
84
|
return listFiles(args)
|
|
@@ -196,6 +227,22 @@ end tell`
|
|
|
196
227
|
return { repo: await repoOfFolder(args.path) }
|
|
197
228
|
},
|
|
198
229
|
|
|
230
|
+
/** Which Claude account Claude Code on this Mac is signed in to (`claude auth status --json`). */
|
|
231
|
+
async claude_account() {
|
|
232
|
+
const { execFile } = await import('node:child_process')
|
|
233
|
+
const bin = process.env.KIRA_CLAUDE_BIN || 'claude'
|
|
234
|
+
return new Promise(resolve =>
|
|
235
|
+
execFile(bin, ['auth', 'status', '--json'], { timeout: 10_000 }, (err, stdout) => {
|
|
236
|
+
try {
|
|
237
|
+
const s = JSON.parse(stdout)
|
|
238
|
+
resolve({ loggedIn: !!s.loggedIn, email: s.email ?? null, plan: s.subscriptionType ?? null, org: s.orgName ?? null, method: s.authMethod ?? null })
|
|
239
|
+
} catch {
|
|
240
|
+
resolve({ error: err?.code === 'ENOENT' ? 'Claude Code is not installed on this Mac' : 'Claude Code did not say which account it uses' })
|
|
241
|
+
}
|
|
242
|
+
}),
|
|
243
|
+
)
|
|
244
|
+
},
|
|
245
|
+
|
|
199
246
|
/** Read-only git facts for a folder: repo, branch, head, dirty files. */
|
|
200
247
|
async git_status(args) {
|
|
201
248
|
const s = await snapshot(resolveCwd(args.path ?? args.cwd))
|
|
@@ -227,6 +274,8 @@ async function withChange(pathArg, fn) {
|
|
|
227
274
|
|
|
228
275
|
let backoff = 1000
|
|
229
276
|
function connect() {
|
|
277
|
+
if (stopped) return
|
|
278
|
+
status('connecting')
|
|
230
279
|
const ws = new WebSocket(`${URL_}?token=${encodeURIComponent(TOKEN)}`)
|
|
231
280
|
let ping
|
|
232
281
|
ws.on('open', () => {
|
|
@@ -251,6 +300,7 @@ function connect() {
|
|
|
251
300
|
log(`sending ${outbox.length} queued job messages`)
|
|
252
301
|
for (const o of outbox.splice(0)) ws.send(JSON.stringify(o))
|
|
253
302
|
}
|
|
303
|
+
status('connected')
|
|
254
304
|
return log(`registered as bridge ${msg.bridgeId} (${Object.keys(tools).length} tools)`)
|
|
255
305
|
}
|
|
256
306
|
if (msg.type === 'job.answer') {
|
|
@@ -277,10 +327,14 @@ function connect() {
|
|
|
277
327
|
if (live === ws) live = null
|
|
278
328
|
if (code === 4001) {
|
|
279
329
|
log('token rejected; create a new one in the portal')
|
|
280
|
-
|
|
330
|
+
status('rejected')
|
|
331
|
+
if (!hooks.onStatus) process.exit(1) // the CLI's behavior; an app decides for itself
|
|
332
|
+
return
|
|
281
333
|
}
|
|
334
|
+
if (stopped) return
|
|
335
|
+
status('disconnected')
|
|
282
336
|
log(`disconnected (${code} ${reason}); retrying in ${backoff / 1000}s`)
|
|
283
|
-
setTimeout(connect, backoff)
|
|
337
|
+
reconnectTimer = setTimeout(connect, backoff)
|
|
284
338
|
backoff = Math.min(backoff * 2, 30_000)
|
|
285
339
|
})
|
|
286
340
|
ws.on('error', err => log(`socket error: ${err.message}`))
|
|
@@ -290,6 +344,8 @@ function send(ws, obj) {
|
|
|
290
344
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
|
|
291
345
|
}
|
|
292
346
|
function log(m) {
|
|
293
|
-
|
|
347
|
+
const line = `[kira-bridge ${new Date().toLocaleTimeString()}] ${m}`
|
|
348
|
+
if (hooks.log) hooks.log(line)
|
|
349
|
+
else console.log(line)
|
|
294
350
|
}
|
|
295
351
|
|