kira-glasses-bridge 1.0.0 → 1.1.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,18 +1,31 @@
1
1
  {
2
2
  "name": "kira-glasses-bridge",
3
- "version": "1.0.0",
3
+ "version": "1.1.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
- "bin": { "kira-bridge": "./src/cli.mjs" },
7
- "files": ["src", "README.md"],
8
- "engines": { "node": ">=20" },
6
+ "bin": {
7
+ "kira-bridge": "src/cli.mjs"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
9
16
  "scripts": {
10
17
  "start": "node src/cli.mjs run"
11
18
  },
12
19
  "dependencies": {
13
20
  "ws": "^8.18.0"
14
21
  },
15
- "keywords": ["even-realities", "g2", "smart-glasses", "claude-code", "kira"],
22
+ "keywords": [
23
+ "even-realities",
24
+ "g2",
25
+ "smart-glasses",
26
+ "claude-code",
27
+ "kira"
28
+ ],
16
29
  "license": "MIT",
17
30
  "homepage": "https://hackchild.xyz/kira"
18
31
  }
@@ -2,7 +2,7 @@
2
2
  import { spawn } from 'node:child_process'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
- import { join } from 'node:path'
5
+ import { isAbsolute, join, resolve } from 'node:path'
6
6
 
7
7
  /**
8
8
  * Find the claude binary. The bridge may be started from a shell whose PATH
@@ -23,32 +23,41 @@ export function findClaude() {
23
23
 
24
24
  const MAX_MS = 15 * 60 * 1000
25
25
 
26
+ /** Kira's paths are relative to home unless absolute, like the file tools. */
27
+ export function resolveCwd(cwd) {
28
+ if (!cwd) return homedir()
29
+ const s = String(cwd).replace(/^~(?=\/|$)/, homedir())
30
+ return isAbsolute(s) ? resolve(s) : resolve(homedir(), s)
31
+ }
32
+
26
33
  export function runClaude({ prompt, sessionId, cwd }) {
27
- return new Promise(resolve => {
34
+ return new Promise(done => {
35
+ const dir = resolveCwd(cwd)
36
+ if (!existsSync(dir)) return done({ error: `folder not found: ${dir}` })
28
37
  const args = ['-p', '--output-format', 'json', '--permission-mode', 'acceptEdits']
29
38
  if (sessionId) args.push('--resume', sessionId)
30
39
  args.push(prompt)
31
- const child = spawn(findClaude(), args, { cwd: cwd || process.env.HOME, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
40
+ const child = spawn(findClaude(), args, { cwd: dir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] })
32
41
  let out = ''
33
42
  let err = ''
34
43
  const timer = setTimeout(() => {
35
44
  child.kill('SIGTERM')
36
- resolve({ error: `Claude did not finish within ${MAX_MS / 60000} minutes` })
45
+ done({ error: `Claude did not finish within ${MAX_MS / 60000} minutes` })
37
46
  }, MAX_MS)
38
47
  child.stdout.on('data', d => (out += d))
39
48
  child.stderr.on('data', d => (err += d))
40
49
  child.on('error', e => {
41
50
  clearTimeout(timer)
42
51
  const hint = e.code === 'ENOENT' ? 'Claude Code is not installed or not on the bridge\'s PATH. Install it, or set KIRA_CLAUDE_BIN=/path/to/claude when starting the bridge.' : e.message
43
- resolve({ error: `could not start claude: ${hint}` })
52
+ done({ error: `could not start claude: ${hint}` })
44
53
  })
45
54
  child.on('close', code => {
46
55
  clearTimeout(timer)
47
56
  try {
48
57
  const j = JSON.parse(out)
49
- resolve({ session_id: j.session_id, result: j.result, is_error: !!j.is_error, cost_usd: j.total_cost_usd, duration_ms: j.duration_ms, num_turns: j.num_turns })
58
+ done({ session_id: j.session_id, result: j.result, is_error: !!j.is_error, cost_usd: j.total_cost_usd, duration_ms: j.duration_ms, num_turns: j.num_turns })
50
59
  } catch {
51
- resolve({ error: `claude exited with code ${code}: ${(err || out).slice(0, 500)}` })
60
+ done({ error: `claude exited with code ${code}: ${(err || out).slice(0, 500)}` })
52
61
  }
53
62
  })
54
63
  })
package/src/files.mjs CHANGED
@@ -93,7 +93,7 @@ export async function createFolder({ path }) {
93
93
  * Refuses to overwrite unless `overwrite` is true, so a slip of the tongue
94
94
  * cannot clobber something.
95
95
  */
96
- export async function writeUserFile({ path, content, overwrite }) {
96
+ export async function writeUserFile({ path, content, overwrite, append }) {
97
97
  const full = resolveUserPath(String(path ?? ''))
98
98
  if (full === HOME) throw new Error('give the file a name')
99
99
  const { mkdir, stat, writeFile } = await import('node:fs/promises')
@@ -101,12 +101,19 @@ export async function writeUserFile({ path, content, overwrite }) {
101
101
  try {
102
102
  const st = await stat(full)
103
103
  if (st.isDirectory()) throw new Error(`${display(full)} is a folder`)
104
- if (!overwrite) return { path: display(full), written: false, error: `${display(full)} already exists. Say "overwrite it" to replace it.` }
104
+ if (!overwrite && !append) return { path: display(full), written: false, error: `${display(full)} already exists. Use append to add to it, or overwrite to replace it.` }
105
105
  } catch (err) {
106
106
  if (err.code !== 'ENOENT') throw err
107
107
  }
108
108
  await mkdir(dirname(full), { recursive: true })
109
109
  const text = String(content ?? '')
110
+ if (append) {
111
+ const { appendFile, readFile } = await import('node:fs/promises')
112
+ const existing = await readFile(full, 'utf8').catch(() => '')
113
+ const sep = existing && !existing.endsWith('\n') ? '\n' : ''
114
+ await appendFile(full, sep + text + (text.endsWith('\n') ? '' : '\n'), 'utf8')
115
+ return { path: display(full), written: true, appended: true, bytes: Buffer.byteLength(text, 'utf8') }
116
+ }
110
117
  await writeFile(full, text, 'utf8')
111
118
  return { path: display(full), written: true, bytes: Buffer.byteLength(text, 'utf8') }
112
119
  }
package/src/git.mjs ADDED
@@ -0,0 +1,136 @@
1
+ // What Claude changed on disk, from git: a snapshot before a job and the
2
+ // difference after it. Everything is read-only; the working tree is never
3
+ // touched. Missing git or a folder outside a repo simply yields null.
4
+ import { execFile } from 'node:child_process'
5
+ import { homedir } from 'node:os'
6
+ import { relative } from 'node:path'
7
+
8
+ const DIFF_CAP = 400 * 1024 // bytes of unified diff kept per change
9
+ const UNTRACKED_CAP = 30 // new files whose content is included
10
+ const COMMIT_CAP = 20
11
+
12
+ function git(cwd, args, { ok = [0] } = {}) {
13
+ return new Promise(resolve => {
14
+ execFile('git', args, { cwd, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' } }, (err, stdout) => {
15
+ if (err && !ok.includes(err.code)) return resolve(null)
16
+ resolve(String(stdout ?? ''))
17
+ })
18
+ })
19
+ }
20
+
21
+ export function display(full) {
22
+ const rel = relative(homedir(), full)
23
+ return rel && !rel.startsWith('..') ? `~/${rel}` : full
24
+ }
25
+
26
+ /** Repo identity plus the head and dirty files right now; null when not a git repo. */
27
+ export async function snapshot(dir) {
28
+ const root = (await git(dir, ['rev-parse', '--show-toplevel']))?.trim()
29
+ if (!root) return null
30
+ const [head, branch, remote, status] = await Promise.all([
31
+ git(root, ['rev-parse', 'HEAD']),
32
+ git(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
33
+ git(root, ['remote', 'get-url', 'origin']),
34
+ git(root, ['status', '--porcelain=v1', '--untracked-files=all']),
35
+ ])
36
+ return {
37
+ root,
38
+ cwd: display(root),
39
+ head: head?.trim() || null, // null on an empty repo
40
+ branch: branch?.trim() || null,
41
+ repo: remoteToRepo(remote?.trim()),
42
+ dirty: parseStatus(status ?? ''),
43
+ }
44
+ }
45
+
46
+ function remoteToRepo(url) {
47
+ if (!url) return null
48
+ const m = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?$/.exec(url)
49
+ return m ? m[1] : null
50
+ }
51
+
52
+ function parseStatus(out) {
53
+ return out
54
+ .split('\n')
55
+ .filter(Boolean)
56
+ .map(l => ({ status: l.slice(0, 2).trim() || 'M', path: l.slice(3).replace(/^"|"$/g, '') }))
57
+ }
58
+
59
+ /**
60
+ * Everything different between the pre-job head and the working tree now:
61
+ * commits made since, per-file stats, and one unified diff (committed and
62
+ * uncommitted alike, new files included). Files that were already dirty
63
+ * before the job are flagged so a reader can discount them.
64
+ */
65
+ export async function changesSince(before) {
66
+ const { root } = before
67
+ const base = before.head
68
+ const dirtyBefore = new Set(before.dirty.map(d => d.path))
69
+ const [headNow, numstat, nameStatus, diff, untrackedOut] = await Promise.all([
70
+ git(root, ['rev-parse', 'HEAD']),
71
+ base ? git(root, ['diff', '--numstat', '-M', base]) : Promise.resolve(''),
72
+ base ? git(root, ['diff', '--name-status', '-M', base]) : Promise.resolve(''),
73
+ base ? git(root, ['diff', '-M', '--no-color', base]) : Promise.resolve(''),
74
+ git(root, ['ls-files', '--others', '--exclude-standard']),
75
+ ])
76
+ const files = new Map()
77
+ for (const line of (numstat ?? '').split('\n').filter(Boolean)) {
78
+ const [a, d, ...rest] = line.split('\t')
79
+ const path = rest.join('\t').replace(/^.*\{(.*) => (.*)\}.*$/, (m, o, n) => m.replace(`{${o} => ${n}}`, n)).replace(/^(.*) => (.*)$/, '$2')
80
+ files.set(path, { path, adds: a === '-' ? 0 : Number(a), dels: d === '-' ? 0 : Number(d), binary: a === '-', status: 'M' })
81
+ }
82
+ for (const line of (nameStatus ?? '').split('\n').filter(Boolean)) {
83
+ const [st, ...rest] = line.split('\t')
84
+ const path = rest[rest.length - 1]
85
+ const f = files.get(path)
86
+ if (f) f.status = st[0]
87
+ }
88
+ let text = diff ?? ''
89
+ const untracked = (untrackedOut ?? '').split('\n').filter(Boolean)
90
+ let shown = 0
91
+ for (const path of untracked) {
92
+ const isNew = !dirtyBefore.has(path)
93
+ const content = shown < UNTRACKED_CAP ? await git(root, ['diff', '--no-index', '--no-color', '--', '/dev/null', path], { ok: [0, 1] }) : null
94
+ let adds = 0
95
+ if (content) {
96
+ adds = (content.match(/^\+(?!\+\+)/gm) ?? []).length
97
+ if (text.length < DIFF_CAP) text += content
98
+ shown++
99
+ }
100
+ files.set(path, { path, adds, dels: 0, binary: content === null && shown < UNTRACKED_CAP, status: 'A', untracked: true, preexisting: !isNew })
101
+ }
102
+ for (const f of files.values()) if (dirtyBefore.has(f.path)) f.preexisting = true
103
+ const truncated = text.length > DIFF_CAP
104
+ if (truncated) text = text.slice(0, DIFF_CAP) + '\n… diff truncated …\n'
105
+
106
+ const commits = []
107
+ if (base && headNow && headNow.trim() !== base) {
108
+ const log = await git(root, ['log', '--format=%H%x1f%s%x1f%cI', `${base}..HEAD`, `--max-count=${COMMIT_CAP}`])
109
+ for (const line of (log ?? '').split('\n').filter(Boolean)) {
110
+ const [sha, title, at] = line.split('\x1f')
111
+ const ns = await git(root, ['show', '--numstat', '--format=', sha])
112
+ let adds = 0, dels = 0, n = 0
113
+ for (const l of (ns ?? '').split('\n').filter(Boolean)) {
114
+ const [a, d] = l.split('\t')
115
+ if (a !== '-') adds += Number(a)
116
+ if (d !== '-') dels += Number(d)
117
+ n++
118
+ }
119
+ commits.push({ sha, short: sha.slice(0, 7), title, at, files: n, adds, dels })
120
+ }
121
+ }
122
+ const list = [...files.values()]
123
+ if (list.length === 0 && commits.length === 0) return null
124
+ return {
125
+ cwd: before.cwd,
126
+ repo: before.repo,
127
+ branch: (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']))?.trim() || before.branch,
128
+ head_before: base,
129
+ head_after: headNow?.trim() || null,
130
+ commits,
131
+ files: list,
132
+ stats: { files: list.length, adds: list.reduce((s, f) => s + f.adds, 0), dels: list.reduce((s, f) => s + f.dels, 0) },
133
+ diff: text,
134
+ truncated,
135
+ }
136
+ }
package/src/index.mjs CHANGED
@@ -3,9 +3,10 @@
3
3
  // machine on the user's behalf. Env: KIRA_BRIDGE_URL, KIRA_BRIDGE_TOKEN.
4
4
  import WebSocket from 'ws'
5
5
  import { listProjects, listSessions, readSession } from './claude-sessions.mjs'
6
- import { runClaude, findClaude } from './claude-run.mjs'
6
+ import { snapshot, changesSince, display } from './git.mjs'
7
+ import { runClaude, findClaude, resolveCwd } from './claude-run.mjs'
7
8
  import { openApp, installedApps } from './apps.mjs'
8
- import { listFiles, readUserFile, createFolder, writeUserFile, trashPath } from './files.mjs'
9
+ import { listFiles, readUserFile, createFolder, writeUserFile, trashPath, resolveUserPath } from './files.mjs'
9
10
 
10
11
  import { createRequire } from 'node:module'
11
12
  const { version: BRIDGE_VERSION } = createRequire(import.meta.url)('../package.json')
@@ -34,16 +35,16 @@ const tools = {
34
35
  return readUserFile(args)
35
36
  },
36
37
  async create_folder(args) {
37
- return createFolder(args)
38
+ return withChange(args.path, () => createFolder(args))
38
39
  },
39
40
  async write_file(args) {
40
- return writeUserFile(args)
41
+ return withChange(args.path, () => writeUserFile(args))
41
42
  },
42
43
  async open_app(args) {
43
44
  return openApp(args)
44
45
  },
45
46
  async delete_path(args) {
46
- return trashPath(args)
47
+ return withChange(args.path, () => trashPath(args))
47
48
  },
48
49
  async list_apps() {
49
50
  return { apps: await installedApps() }
@@ -60,17 +61,56 @@ const tools = {
60
61
  // Acknowledge now, report the result as a job when Claude finishes.
61
62
  async ask_claude(args, ws) {
62
63
  const jobId = String(args.job_id ?? '')
63
- const prompt = String(args.prompt ?? '')
64
+ const prompt = String(args.prompt ?? args.message ?? args.text ?? args.instruction ?? '').trim()
64
65
  if (!prompt) return { error: 'prompt is required' }
65
- log(`ask_claude job=${jobId.slice(0, 8)} session=${args.session_id ?? 'new'}: ${prompt.slice(0, 80)}`)
66
- void runClaude({ prompt, sessionId: args.session_id, cwd: args.cwd }).then(result => {
67
- log(`job ${jobId.slice(0, 8)} ${result.error ? 'error' : 'done'}`)
68
- send(ws, { type: 'job', jobId, status: result.error ? 'error' : 'done', result, error: result.error })
66
+ // A resumed session runs in its own folder even when the caller did not say which.
67
+ let cwd = args.cwd
68
+ if (!cwd && args.session_id) {
69
+ const s = await readSession(String(args.session_id), 1)
70
+ if (s?.cwd) cwd = s.cwd
71
+ }
72
+ const dir = resolveCwd(cwd)
73
+ log(`ask_claude job=${jobId.slice(0, 8)} session=${args.session_id ?? 'new'} in ${display(dir)}: ${prompt.slice(0, 80)}`)
74
+ // Git before and after, so the API can show exactly what this job changed.
75
+ const before = await snapshot(dir).catch(() => null)
76
+ void runClaude({ prompt, sessionId: args.session_id, cwd: dir }).then(async result => {
77
+ let change = null
78
+ if (before) change = await changesSince(before).catch(err => (log(`git after job failed: ${err.message}`), null))
79
+ log(`job ${jobId.slice(0, 8)} ${result.error ? 'error' : 'done'}${change ? ` · ${change.stats.files} files +${change.stats.adds} -${change.stats.dels}, ${change.commits.length} commits` : ''}`)
80
+ send(ws, { type: 'job', jobId, status: result.error ? 'error' : 'done', result: { ...result, cwd: display(dir), change }, error: result.error })
69
81
  })
70
- return { started: true }
82
+ return { started: true, cwd: display(dir), git: before ? { repo: before.repo, branch: before.branch, head: before.head?.slice(0, 7) ?? null } : null }
83
+ },
84
+
85
+ /** Read-only git facts for a folder: repo, branch, head, dirty files. */
86
+ async git_status(args) {
87
+ const s = await snapshot(resolveCwd(args.path ?? args.cwd))
88
+ if (!s) return { error: 'not a git repository' }
89
+ return { cwd: s.cwd, repo: s.repo, branch: s.branch, head: s.head?.slice(0, 7) ?? null, dirty: s.dirty.slice(0, 50), dirty_count: s.dirty.length }
71
90
  },
72
91
  }
73
92
 
93
+ /**
94
+ * Run a file-changing tool with a git snapshot around it, so the API can show
95
+ * the diff the same way it does for Claude jobs. Outside a repo: no change.
96
+ */
97
+ async function withChange(pathArg, fn) {
98
+ let before = null
99
+ try {
100
+ const full = resolveUserPath(String(pathArg ?? ''))
101
+ const { dirname } = await import('node:path')
102
+ const { stat } = await import('node:fs/promises')
103
+ const isDir = await stat(full).then(s => s.isDirectory()).catch(() => false)
104
+ before = await snapshot(isDir ? full : dirname(full))
105
+ } catch {
106
+ before = null
107
+ }
108
+ const result = await fn()
109
+ if (!before || (result && result.error)) return result
110
+ const change = await changesSince(before).catch(() => null)
111
+ return change ? { ...result, change } : result
112
+ }
113
+
74
114
  let backoff = 1000
75
115
  function connect() {
76
116
  const ws = new WebSocket(`${URL_}?token=${encodeURIComponent(TOKEN)}`)