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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "2.9.0",
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://hackchild.xyz/kira"
31
+ "homepage": "https://kira.glass"
32
32
  }
@@ -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 } : {}),
@@ -1,42 +1,13 @@
1
- // Reads Claude Code's own session transcripts under ~/.claude/projects.
2
- // Each session is a .jsonl file; the entries we care about are user and
3
- // assistant messages, the ai-title entries, and cwd.
4
- import { readdir, readFile, stat } from 'node:fs/promises'
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
- /** Parse a transcript; only the last `tailBytes` are read for speed. */
48
- async function summarize(file, tailBytes = 400_000) {
49
- const st = await stat(file.path)
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 line of lines) {
59
- if (!line) continue
60
- let d
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
- d = JSON.parse(line)
63
- } catch {
64
- continue
65
- }
66
- if (d.sessionId) sessionId = d.sessionId
67
- if (d.cwd) cwd = d.cwd
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 { sessionId, cwd, title, turns, lastActiveAt: new Date(st.mtimeMs).toISOString(), active: Date.now() - st.mtimeMs < ACTIVE_WINDOW_MS }
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 files = (await sessionFiles()).slice(0, Math.min(Math.max(Number(max) || 10, 1), 20))
57
+ const n = Math.min(Math.max(Number(max) || 10, 1), 20)
80
58
  const sessions = []
81
- for (const f of files) {
59
+ for (const s of await sdkListSessions({ limit: n })) {
82
60
  try {
83
- const s = await summarize(f, 120_000)
84
- const lastUser = [...s.turns].reverse().find(t => t.role === 'user')
85
- const lastClaude = [...s.turns].reverse().find(t => t.role === 'claude')
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.title ?? (lastUser ? lastUser.text.slice(0, 60) : '(untitled)'),
89
- project: s.cwd ? s.cwd.split('/').slice(-2).join('/') : null,
90
- cwd: s.cwd,
91
- active: s.active,
92
- last_active_at: s.lastActiveAt,
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 files = await sessionFiles()
105
- const file = files.find(f => f.path.endsWith(`/${sessionId}.jsonl`))
106
- if (!file) return { error: `No session ${sessionId} on this machine` }
107
- const s = await summarize(file)
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: s.sessionId, title: s.title, cwd: s.cwd, active: s.active, last_active_at: s.lastActiveAt, turns: s.turns.slice(-n).map(t => ({ ...t, text: t.text.slice(0, 2000) })) }
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
- let dirs = []
119
- try {
120
- dirs = await readdir(PROJECTS)
121
- } catch {
122
- return { count: 0, projects: [] }
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 d of dirs) {
126
- const dir = join(PROJECTS, d)
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: files.length, last_active_at: new Date(newest.mtime).toISOString(), _mtime: newest.mtime })
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
- projects.sort((a, b) => b._mtime - a._mtime)
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
- /** Start the bridge with resolved settings. Runs until the process exits. */
39
- export function startBridge(config) {
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
- console.error('No token. Run: npx kira-glasses-bridge --token <token> (from the Kira portal → Connections → Claude Code)')
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
- process.exit(1)
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
- console.log(`[kira-bridge ${new Date().toLocaleTimeString()}] ${m}`)
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