kira-glasses-bridge 2.12.0 → 2.14.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.12.0",
3
+ "version": "2.14.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": {
@@ -0,0 +1,180 @@
1
+ // Designs Claude Code made on this Mac: artifacts it published to claude.ai, and the HTML/SVG
2
+ // mockups it wrote (design/ folders, Claude Design projects, scratchpad pages). Read through the
3
+ // Agent SDK like claude-sessions.mjs. A design's HTML comes from the file when it's still on disk,
4
+ // and otherwise from the transcript itself (its Write, then its Edits replayed), since most were
5
+ // written to a scratchpad that macOS has since cleared.
6
+ import { getSessionMessages, getSubagentMessages, listSessions as sdkListSessions, listSubagents } from '@anthropic-ai/claude-agent-sdk'
7
+ import { createHash } from 'node:crypto'
8
+ import { readFile, stat } from 'node:fs/promises'
9
+ import { homedir } from 'node:os'
10
+ import { basename, dirname, extname, relative } from 'node:path'
11
+
12
+ const MAX_BYTES = 2 * 1024 * 1024
13
+ const DESIGN_DIR = /^(design|designs|mock|mocks|mockups?|prototypes?|wireframes?)$/i
14
+ const DESIGN_NAME = /(mock|design|prototype|wireframe|preview)/i
15
+ const NEVER = /^(node_modules|dist|build|out|\.git|\.next|coverage)$/
16
+
17
+ const sha = s => createHash('sha256').update(s).digest('hex')
18
+
19
+ /** Whether a file Claude wrote is a design rather than app source. */
20
+ function isDesignPath(path) {
21
+ const ext = extname(path).toLowerCase()
22
+ if (ext !== '.html' && ext !== '.svg') return false
23
+ const parts = path.split('/')
24
+ if (parts.some(p => NEVER.test(p))) return false
25
+ if (path.includes('/scratchpad/') || path.endsWith('.dc.html')) return true
26
+ return parts.slice(0, -1).some(p => DESIGN_DIR.test(p)) || DESIGN_NAME.test(basename(path))
27
+ }
28
+
29
+ /** The folder a design sits in, as a short label that groups a set of screens ("kira-cards", "design/assignee-menu"). */
30
+ function collectionOf(path, cwd) {
31
+ const pad = path.indexOf('/scratchpad/')
32
+ let dir = pad >= 0 ? dirname(path.slice(pad + '/scratchpad/'.length)) : cwd && path.startsWith(cwd + '/') ? dirname(relative(cwd, path)) : basename(dirname(path))
33
+ dir = dir.replace(/\/project$/, '')
34
+ return dir && dir !== '.' ? dir : null
35
+ }
36
+
37
+ /** The repo a session worked in: Kira task worktrees are ~/.kira/worktrees/<owner>/<repo>/<task>. */
38
+ function repoOf(cwd) {
39
+ if (!cwd) return null
40
+ if (cwd === homedir()) return 'Home'
41
+ const m = /\/\.kira\/worktrees\/[^/]+\/([^/]+)\//.exec(cwd + '/')
42
+ return m ? m[1] : basename(cwd)
43
+ }
44
+
45
+ function titleOf(path, html) {
46
+ const t = /<title[^>]*>([^<]{1,120})<\/title>/i.exec(html ?? '')?.[1]?.trim()
47
+ if (t) return t.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
48
+ return basename(path).replace(/(\.dc)?\.(html|svg)$/i, '').replace(/^\d+[-_]/, '').replace(/[-_]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
49
+ }
50
+
51
+ function applyEdit(text, e) {
52
+ if (text == null || typeof e?.old_string !== 'string' || !text.includes(e.old_string)) return null
53
+ return e.replace_all ? text.split(e.old_string).join(e.new_string ?? '') : text.replace(e.old_string, () => e.new_string ?? '')
54
+ }
55
+
56
+ const resultText = c => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(b => b?.text ?? '').join('\n') : '')
57
+
58
+ /** Every design one session made, with its latest HTML as of the end of the transcript. */
59
+ async function designsOf(s) {
60
+ const opts = s.cwd ? { dir: s.cwd } : undefined
61
+ // Subagents write designs too (a design pass is often delegated), so read their transcripts after the session's own.
62
+ const messages = await getSessionMessages(s.sessionId, opts)
63
+ for (const agent of await listSubagents(s.sessionId, opts).catch(() => [])) messages.push(...(await getSubagentMessages(s.sessionId, agent, opts).catch(() => [])))
64
+ const files = new Map() // path -> { content, created, updated }
65
+ const publishes = new Map() // tool_use id -> Artifact publish input
66
+ const artifacts = new Map() // url -> { path, description, icon, title, version, created, updated }
67
+ for (const m of messages) {
68
+ const at = m.timestamp ?? null
69
+ const content = m.message?.content
70
+ if (!Array.isArray(content)) continue
71
+ for (const b of content) {
72
+ if (b?.type === 'tool_use') {
73
+ const i = b.input ?? {}
74
+ const path = typeof i.file_path === 'string' ? i.file_path : null
75
+ if (b.name === 'Write' && path) {
76
+ const f = files.get(path)
77
+ files.set(path, { content: typeof i.content === 'string' ? i.content : null, created: f?.created ?? at, updated: at })
78
+ } else if ((b.name === 'Edit' || b.name === 'MultiEdit') && path && files.has(path)) {
79
+ const f = files.get(path)
80
+ for (const e of b.name === 'Edit' ? [i] : (i.edits ?? [])) f.content = applyEdit(f.content, e)
81
+ f.updated = at
82
+ } else if (b.name === 'Artifact' && (i.action ?? 'publish') === 'publish' && !i.asset) {
83
+ publishes.set(b.id, i)
84
+ }
85
+ } else if (b?.type === 'tool_result' && publishes.has(b.tool_use_id)) {
86
+ const i = publishes.get(b.tool_use_id)
87
+ const m2 = /at (https:\/\/claude\.ai\/\S*artifact\/[\w-]+)(?: \(Version (\d+)\))?/.exec(resultText(b.content))
88
+ if (!m2 || b.is_error) continue
89
+ const prev = artifacts.get(m2[1])
90
+ artifacts.set(m2[1], {
91
+ path: i.file_path ?? prev?.path ?? null,
92
+ description: i.description ?? prev?.description ?? null,
93
+ icon: i.icon ?? prev?.icon ?? null,
94
+ title: i.title ?? prev?.title ?? null,
95
+ version: Number(m2[2]) || prev?.version || 1,
96
+ created: prev?.created ?? at,
97
+ updated: at,
98
+ })
99
+ }
100
+ }
101
+ }
102
+
103
+ const out = []
104
+ const published = new Map([...artifacts].filter(([, a]) => a.path).map(([url, a]) => [a.path, { url, ...a }]))
105
+ const base = { session_id: s.sessionId, session_title: s.customTitle || s.summary || s.firstPrompt?.slice(0, 80) || null, cwd: s.cwd ?? null, repo: repoOf(s.cwd) }
106
+ for (const [path, f] of files) {
107
+ const art = published.get(path)
108
+ if (!art && !isDesignPath(path)) continue
109
+ const onDisk = await stat(path).then(st => st.isFile() && st.size <= MAX_BYTES, () => false)
110
+ const html = onDisk ? await readFile(path, 'utf8').catch(() => f.content) : f.content
111
+ if (!html || Buffer.byteLength(html) > MAX_BYTES) continue
112
+ out.push({
113
+ ...base,
114
+ key: sha(`${s.sessionId}:${path}`).slice(0, 32),
115
+ kind: art ? 'artifact' : path.endsWith('.dc.html') ? 'claude_design' : extname(path).slice(1).toLowerCase(),
116
+ title: art?.title || titleOf(path, html),
117
+ description: art?.description ?? null,
118
+ icon: art?.icon ?? null,
119
+ path: path.replace(homedir(), '~'),
120
+ collection: collectionOf(path, s.cwd),
121
+ artifact_url: art?.url ?? null,
122
+ artifact_version: art?.version ?? null,
123
+ on_disk: onDisk,
124
+ created_at: art?.created ?? f.created,
125
+ updated_at: art?.updated && art.updated > f.updated ? art.updated : f.updated,
126
+ content_hash: sha(html),
127
+ bytes: Buffer.byteLength(html),
128
+ html,
129
+ })
130
+ }
131
+ // Published without a file Claude wrote in this session (a deck, a doc, or a page from an earlier one).
132
+ for (const [url, a] of artifacts) {
133
+ if (a.path && files.has(a.path)) continue
134
+ const html = a.path ? await readFile(a.path, 'utf8').catch(() => null) : null
135
+ out.push({
136
+ ...base,
137
+ key: sha(url).slice(0, 32),
138
+ kind: 'artifact',
139
+ title: a.title || (a.path ? titleOf(a.path, html) : 'Untitled artifact'),
140
+ description: a.description,
141
+ icon: a.icon,
142
+ path: a.path ? a.path.replace(homedir(), '~') : null,
143
+ collection: a.path ? collectionOf(a.path, s.cwd) : null,
144
+ artifact_url: url,
145
+ artifact_version: a.version,
146
+ on_disk: !!html,
147
+ created_at: a.created,
148
+ updated_at: a.updated,
149
+ content_hash: html ? sha(html) : null,
150
+ bytes: html ? Buffer.byteLength(html) : 0,
151
+ html,
152
+ })
153
+ }
154
+ return out
155
+ }
156
+
157
+ /**
158
+ * Scan every session changed since `since` (ISO time; all of them when omitted). Returns the
159
+ * designs without their HTML, plus the HTML of those whose hash isn't in `known` (hashes the API
160
+ * already holds), so a rescan sends only what changed.
161
+ */
162
+ export async function scanDesigns({ since, known = [] } = {}) {
163
+ const after = since ? Date.parse(since) || 0 : 0
164
+ const have = new Set(known)
165
+ const sessions = (await sdkListSessions()).filter(s => s.lastModified > after)
166
+ const designs = []
167
+ let failed = 0
168
+ for (const s of sessions) {
169
+ try {
170
+ designs.push(...(await designsOf(s)))
171
+ } catch {
172
+ failed++
173
+ }
174
+ }
175
+ return {
176
+ scanned: sessions.length,
177
+ failed,
178
+ designs: designs.map(({ html, ...d }) => ({ ...d, html: html && !have.has(d.content_hash) ? html : undefined })),
179
+ }
180
+ }
package/src/index.mjs CHANGED
@@ -3,6 +3,7 @@
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 { scanDesigns } from './claude-designs.mjs'
6
7
  import { snapshot, changesSince, display, setupProjectFolder } from './git.mjs'
7
8
  import { resolveCwd, folderExists } from './claude-run.mjs'
8
9
  import { agentById, AGENTS } from './agents/index.mjs'
@@ -110,6 +111,9 @@ const tools = {
110
111
  async list_claude_projects(args) {
111
112
  return listProjects(args.max)
112
113
  },
114
+ async scan_claude_designs(args) {
115
+ return scanDesigns({ since: args.since, known: Array.isArray(args.known) ? args.known : [] })
116
+ },
113
117
  async list_claude_sessions(args) {
114
118
  return listSessions(args.max)
115
119
  },
package/src/worktrees.mjs CHANGED
@@ -237,9 +237,17 @@ function mimeOf(name) {
237
237
  }
238
238
 
239
239
  /** Files under an output folder of a worktree (a designer's design/, a researcher's docs/research/), with sizes and types. */
240
- export async function listOutputs({ path: p, dir }) {
240
+ export async function listOutputs({ path: p, dir, only }) {
241
241
  const path = resolveUserPath(String(p ?? ''))
242
242
  const base = join(path, String(dir ?? '').replace(/^\/+|\/+$/g, ''))
243
+ // One named file (a terminal session's task_attach) may sit anywhere in the worktree, the root too.
244
+ if (typeof only === 'string' && only) {
245
+ const full = join(base, only)
246
+ if (only.includes('/') || !(base === path || base.startsWith(path + '/')) || !full.startsWith(path + '/')) return { error: 'bad output file' }
247
+ const s = await stat(full).catch(() => null)
248
+ if (!s?.isFile()) return { dir, files: [], bytes: 0 }
249
+ return { dir, files: [{ rel: only, bytes: s.size, mime: mimeOf(only) }], bytes: s.size }
250
+ }
243
251
  if (!base.startsWith(path + '/')) return { error: 'bad output folder' }
244
252
  const { readdir } = await import('node:fs/promises')
245
253
  const out = []