kira-glasses-bridge 2.35.0 → 2.37.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.35.0",
3
+ "version": "2.37.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": {
package/src/chrome.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // Chrome on this Mac, for Links: each profile's bookmarks, read from the Bookmarks file Chrome keeps
2
+ // synced with the Google account signed in to it, and the tabs open right now. Read only: Chrome
3
+ // rewrites that file from its own copy while it runs, so Kira never writes to it.
4
+ import { execFile } from 'node:child_process'
5
+ import { readFile } from 'node:fs/promises'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+
9
+ const CHROME_DIR = join(homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
10
+ const SEP = ' › '
11
+
12
+ /** Chrome's times are microseconds since 1601; an ISO date, or null when there's none. */
13
+ export function chromeTime(raw) {
14
+ const micros = Number(raw)
15
+ if (!Number.isFinite(micros) || micros <= 0) return null
16
+ return new Date(micros / 1000 - 11_644_473_600_000).toISOString()
17
+ }
18
+
19
+ /** The profiles in Chrome's Local State: folder, the name Chrome shows, and the Google account. */
20
+ export function profilesOf(localState) {
21
+ const cache = localState?.profile?.info_cache ?? {}
22
+ return Object.entries(cache).map(([profile, info]) => ({
23
+ profile,
24
+ name: String(info?.name || profile),
25
+ account: info?.user_name ? String(info.user_name) : null,
26
+ }))
27
+ }
28
+
29
+ /** Every bookmark in a Bookmarks file, flat, each with its folders from the root ("Bookmarks bar › Dev"). */
30
+ export function flattenBookmarks(file) {
31
+ const out = []
32
+ const walk = (node, path) => {
33
+ if (node?.type === 'url' && /^https?:/i.test(node.url ?? '')) {
34
+ out.push({
35
+ chromeId: String(node.guid || node.id),
36
+ url: node.url,
37
+ title: String(node.name ?? ''),
38
+ folder: path.join(SEP),
39
+ addedAt: chromeTime(node.date_added),
40
+ })
41
+ }
42
+ for (const child of node?.children ?? []) walk(child, node.type === 'folder' ? [...path, node.name] : path)
43
+ }
44
+ for (const root of Object.values(file?.roots ?? {})) {
45
+ if (root && typeof root === 'object' && root.type === 'folder') {
46
+ for (const child of root.children ?? []) walk(child, [root.name])
47
+ }
48
+ }
49
+ return out
50
+ }
51
+
52
+ const readJson = async path => JSON.parse(await readFile(path, 'utf8'))
53
+
54
+ /** Each profile with its bookmarks; none when Chrome isn't installed. */
55
+ async function bookmarks() {
56
+ let state
57
+ try {
58
+ state = await readJson(join(CHROME_DIR, 'Local State'))
59
+ } catch {
60
+ return { profiles: [] }
61
+ }
62
+ const profiles = await Promise.all(
63
+ profilesOf(state).map(async p => {
64
+ const file = await readJson(join(CHROME_DIR, p.profile, 'Bookmarks')).catch(() => null)
65
+ return { ...p, bookmarks: file ? flattenBookmarks(file) : [] }
66
+ }),
67
+ )
68
+ return { profiles }
69
+ }
70
+
71
+ // Asks Chrome only when it's already running (running() doesn't start it).
72
+ const TABS_SCRIPT = `
73
+ const chrome = Application('Google Chrome')
74
+ chrome.running()
75
+ ? JSON.stringify(chrome.windows().flatMap(w => w.tabs().map(t => ({ url: t.url(), title: t.title() }))))
76
+ : '[]'`
77
+
78
+ /** The tabs open in Chrome now. The first time, macOS asks whether Kira may control Chrome. */
79
+ function tabs() {
80
+ return new Promise(resolve => {
81
+ execFile('osascript', ['-l', 'JavaScript', '-e', TABS_SCRIPT], { timeout: 10_000 }, (err, stdout) => {
82
+ if (err) {
83
+ const denied = /-1743|not allowed/i.test(String(err.message))
84
+ const error = denied
85
+ ? 'Kira isn’t allowed to read Chrome’s tabs (System Settings › Privacy › Automation).'
86
+ : 'Chrome’s tabs couldn’t be read.'
87
+ return resolve({ tabs: [], error })
88
+ }
89
+ try {
90
+ const list = JSON.parse(stdout.trim() || '[]').filter(t => /^https?:/i.test(t.url ?? ''))
91
+ resolve({ tabs: list })
92
+ } catch {
93
+ resolve({ tabs: [] })
94
+ }
95
+ })
96
+ })
97
+ }
98
+
99
+ /** The bridge's `chrome` tool: { op: 'bookmarks' } or { op: 'tabs' }. */
100
+ export function chromeCall(args) {
101
+ if (args?.op === 'bookmarks') return bookmarks()
102
+ if (args?.op === 'tabs') return tabs()
103
+ return Promise.resolve({ error: `unknown op ${args?.op}` })
104
+ }
@@ -5,29 +5,22 @@ import { getSessionInfo, getSessionMessages, listSessions as sdkListSessions } f
5
5
  import { open, readdir, stat } from 'node:fs/promises'
6
6
  import { homedir } from 'node:os'
7
7
  import { basename, join } from 'node:path'
8
+ import { doingOf, itemsOf, lastTurns, turnsOf } from './claude-transcript.mjs'
8
9
 
9
10
  const ACTIVE_WINDOW_MS = 3 * 60 * 1000
11
+ const TEXT_CAP = 2000
10
12
 
11
- function textOf(message) {
12
- const c = message?.content
13
- if (typeof c === 'string') return c
14
- if (Array.isArray(c)) return c.filter(b => b?.type === 'text').map(b => b.text).join('\n')
15
- return ''
16
- }
17
-
18
- /** The conversation as said: user and Claude text, without tool results, system blocks or subagents. */
19
- async function turnsOf(sessionId, dir) {
13
+ /** The session as it happened: its turns and the tool calls between them (claude-transcript.mjs). */
14
+ async function itemsFor(sessionId, dir) {
20
15
  const messages = await getSessionMessages(sessionId, dir ? { dir } : undefined)
21
- const turns = []
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
16
+ return itemsOf(messages, dir ?? homedir())
29
17
  }
30
18
 
19
+ /** The branch, unless the checkout is detached ("HEAD"). */
20
+ const branchOf = s => (s.gitBranch && s.gitBranch !== 'HEAD' ? s.gitBranch : null)
21
+
22
+ const capText = item => (item.kind === 'turn' ? { ...item, text: item.text.slice(0, TEXT_CAP) } : item)
23
+
31
24
  /**
32
25
  * The SDK reads a session's folder from the start of its transcript, and misses it when the
33
26
  * transcript opens with large snapshot entries. Then: the first "cwd" in the first MB of the file.
@@ -59,18 +52,22 @@ export async function listSessions(max = 10) {
59
52
  for (const s of await sdkListSessions({ limit: n })) {
60
53
  try {
61
54
  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')
55
+ const items = await itemsFor(s.sessionId, cwd)
56
+ const turns = turnsOf(items)
57
+ const lastUser = turns.findLast(t => t.role === 'user')
58
+ const lastClaude = turns.findLast(t => t.role === 'claude')
59
+ const active = activeOf(s)
65
60
  sessions.push({
66
61
  session_id: s.sessionId,
67
62
  title: titleOf(s) ?? (lastUser ? lastUser.text.slice(0, 60) : '(untitled)'),
68
63
  project: cwd ? cwd.split('/').slice(-2).join('/') : null,
69
64
  cwd,
70
- active: activeOf(s),
65
+ active,
71
66
  last_active_at: new Date(s.lastModified).toISOString(),
72
67
  last_user_message: lastUser ? lastUser.text.slice(0, 200) : null,
73
68
  last_claude_message: lastClaude ? lastClaude.text.slice(0, 200) : null,
69
+ branch: branchOf(s),
70
+ doing: active ? doingOf(items) : null,
74
71
  })
75
72
  } catch {
76
73
  /* unreadable transcript */
@@ -79,13 +76,28 @@ export async function listSessions(max = 10) {
79
76
  return { count: sessions.length, sessions }
80
77
  }
81
78
 
82
- export async function readSession(sessionId, turns = 6) {
79
+ /**
80
+ * A session's last `turns` turns; with `items`, the tool calls between them too (the phone asks for
81
+ * them; Kira's own reads don't, to keep them short).
82
+ */
83
+ export async function readSession(sessionId, turns = 6, { items: withItems = false } = {}) {
83
84
  const info = await getSessionInfo(sessionId).catch(() => undefined)
84
85
  if (!info) return { error: `No session ${sessionId} on this machine` }
85
86
  const cwd = await cwdOf(info)
86
- const all = await turnsOf(sessionId, cwd)
87
+ const items = await itemsFor(sessionId, cwd)
87
88
  const n = Math.min(Math.max(Number(turns) || 6, 1), 30)
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) })) }
89
+ const recent = lastTurns(items, n).map(capText)
90
+ return {
91
+ session_id: info.sessionId,
92
+ title: titleOf(info),
93
+ cwd,
94
+ active: activeOf(info),
95
+ last_active_at: new Date(info.lastModified).toISOString(),
96
+ turns: turnsOf(recent),
97
+ ...(withItems ? { items: recent } : {}),
98
+ branch: branchOf(info),
99
+ doing: activeOf(info) ? doingOf(items) : null,
100
+ }
89
101
  }
90
102
 
91
103
  /** Project folders Claude Code has been used in (that still exist), newest first. */
@@ -0,0 +1,114 @@
1
+ // A Claude Code transcript (the Agent SDK's session messages) as the conversation shows it: what was
2
+ // said and, between the turns, the tool calls Claude made, described the way its jobs describe them
3
+ // (agents/claude-calls.mjs). The shape is @kira/genui's claudeSessionItems.ts.
4
+ import { describeAction, short } from './agents/claude-calls.mjs'
5
+
6
+ /** How much of an edit, a write or a command a call carries for its detail. */
7
+ const DETAIL_CAP = 1500
8
+
9
+ function textOf(message) {
10
+ const c = message?.content
11
+ if (typeof c === 'string') return c
12
+ if (Array.isArray(c)) return c.filter(b => b?.type === 'text').map(b => b.text).join('\n')
13
+ return ''
14
+ }
15
+
16
+ /** An edit's lines: the old and new text without the lines they share at the start and the end. */
17
+ function diffCounts(old, now) {
18
+ const a = String(old ?? '').split('\n')
19
+ const b = String(now ?? '').split('\n')
20
+ let start = 0
21
+ while (start < a.length && start < b.length && a[start] === b[start]) start++
22
+ let end = 0
23
+ while (end < a.length - start && end < b.length - start && a[a.length - 1 - end] === b[b.length - 1 - end]) end++
24
+ return { adds: b.length - start - end, dels: a.length - start - end }
25
+ }
26
+
27
+ /** Lines an edit or a write adds and takes out; null for any other call. */
28
+ export function lineCounts(name, input) {
29
+ const i = input ?? {}
30
+ if (name === 'Write') return { adds: i.content ? String(i.content).split('\n').length : 0, dels: 0 }
31
+ if (name === 'Edit') return diffCounts(i.old_string, i.new_string)
32
+ if (name !== 'MultiEdit') return null
33
+ const each = (i.edits ?? []).map(e => diffCounts(e.old_string, e.new_string))
34
+ return { adds: each.reduce((n, c) => n + c.adds, 0), dels: each.reduce((n, c) => n + c.dels, 0) }
35
+ }
36
+
37
+ /**
38
+ * A change's detail, shorter than a job's: the conversation polls it every few seconds. A command
39
+ * has none (its target is the command).
40
+ */
41
+ function shortDetail(d) {
42
+ const cut = e => ({ ...e, old: short(e.old, DETAIL_CAP), new: short(e.new, DETAIL_CAP) })
43
+ if (d.edit) return { edit: cut(d.edit) }
44
+ if (d.edits) return { edits: d.edits.slice(0, 5).map(cut) }
45
+ return d.content !== undefined ? { content: short(d.content, DETAIL_CAP) } : undefined
46
+ }
47
+
48
+ /** One tool_use block as a call. */
49
+ function toolCall(block, cwd) {
50
+ const act = describeAction(block.name, block.input, cwd)
51
+ const detail = act.detail ? shortDetail(act.detail) : undefined
52
+ return {
53
+ id: block.id,
54
+ type: act.type,
55
+ label: act.label,
56
+ target: act.target,
57
+ ...(lineCounts(block.name, block.input) ?? {}),
58
+ ...(detail ? { detail } : {}),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Everything said and done, in order: the user's and Claude's turns, and each run of tool calls
64
+ * between them as one item. Subagents' own messages and system blocks (text starting "<") are left out.
65
+ */
66
+ export function itemsOf(messages, cwd) {
67
+ const items = []
68
+ const calls = new Map()
69
+ let group = null
70
+ const say = (role, text, at) => {
71
+ group = null
72
+ if (text && !text.startsWith('<')) items.push({ kind: 'turn', role, text, at })
73
+ }
74
+ for (const m of messages) {
75
+ if (m.parent_tool_use_id) continue
76
+ const at = m.timestamp ?? null
77
+ const content = Array.isArray(m.message?.content) ? m.message.content : []
78
+ if (m.type === 'assistant') {
79
+ for (const b of content) {
80
+ if (b?.type === 'text') say('claude', String(b.text ?? '').trim(), at)
81
+ if (b?.type !== 'tool_use') continue
82
+ if (!group) items.push((group = { kind: 'tools', at, calls: [] }))
83
+ const call = toolCall(b, cwd)
84
+ calls.set(call.id, call)
85
+ group.calls.push(call)
86
+ }
87
+ } else if (m.type === 'user') {
88
+ for (const b of content) {
89
+ if (b?.type === 'tool_result' && b.is_error && calls.has(b.tool_use_id)) calls.get(b.tool_use_id).failed = true
90
+ }
91
+ const text = textOf(m.message).trim()
92
+ if (text) say('user', text, at)
93
+ }
94
+ }
95
+ return items
96
+ }
97
+
98
+ /** Just the turns (what read_claude_session has always returned). */
99
+ export const turnsOf = items => items.filter(i => i.kind === 'turn').map(({ kind, ...turn }) => turn)
100
+
101
+ /** The last `n` turns and the tool calls among and after them. */
102
+ export function lastTurns(items, n) {
103
+ let seen = 0
104
+ for (let i = items.length - 1; i >= 0; i--) if (items[i].kind === 'turn' && ++seen === n) return items.slice(i)
105
+ return items
106
+ }
107
+
108
+ /** The last call when nothing was said after it: what the session is doing (or did last). */
109
+ export function doingOf(items) {
110
+ const last = items.at(-1)
111
+ if (last?.kind !== 'tools') return null
112
+ const { detail, ...call } = last.calls.at(-1)
113
+ return call
114
+ }
package/src/mac-tools.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // The bridge's tools for this Mac itself: its apps, a terminal, a project's folder (found or new), and
2
- // (inside the Mac app only) its shells for other devices and its browser dock.
2
+ // (inside the Mac app only) its shells for other devices and its browser dock, and Chrome's bookmarks and tabs.
3
3
  import { execFile } from 'node:child_process'
4
4
  import { openApp, installedApps } from './apps.mjs'
5
5
  import { resolveUserPath } from './files.mjs'
@@ -8,6 +8,7 @@ import { findCheckouts } from './checkouts.mjs'
8
8
  import { terminalCall } from './terminals.mjs'
9
9
  import { safeCommand, terminalScript } from './terminal-command.mjs'
10
10
  import { host } from './host.mjs'
11
+ import { chromeCall } from './chrome.mjs'
11
12
 
12
13
  export const macTools = {
13
14
  async open_app(args) {
@@ -39,6 +40,9 @@ export const macTools = {
39
40
  /** This Mac's shells for a browser elsewhere (only inside the Mac app, only when switched on there). */
40
41
  terminal: args => terminalCall(args),
41
42
 
43
+ /** Chrome's bookmarks (every profile) and open tabs, for Links. */
44
+ chrome: args => chromeCall(args),
45
+
42
46
  /** Kira in the Mac app's browser dock: open a page, read it, click and type in it. */
43
47
  async browser(args) {
44
48
  const hooks = host()
@@ -15,6 +15,6 @@ export const sessionTools = {
15
15
  return listSessions(args.max)
16
16
  },
17
17
  async read_claude_session(args) {
18
- return readSession(String(args.session_id ?? ''), args.turns)
18
+ return readSession(String(args.session_id ?? ''), args.turns, { items: args.items === true })
19
19
  },
20
20
  }