@gotcos/glasses-server 6.46.1 → 6.48.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/CHANGELOG.md +25 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +8 -2
- package/server/index.ts +85 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/health.ts +2 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/session-hooks.ts +70 -0
- package/server/routes/voice.ts +18 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
// Installing the COS session hook into `~/.claude/settings.json`, and knowing whether it
|
|
2
|
+
// is installed.
|
|
3
|
+
//
|
|
4
|
+
// USER LEVEL ONLY. Hooks in `~/.claude/settings.json` fire for every Claude Code session
|
|
5
|
+
// on this Mac (Desktop tabs, the CLI, `claude -p` jobs). The project-level file the COS
|
|
6
|
+
// repo owns (seven memory and canvas hooks) is never touched; Claude merges levels, so
|
|
7
|
+
// both sets run.
|
|
8
|
+
//
|
|
9
|
+
// THE MERGE IS A PURE FUNCTION over the settings object (`mergeHookSettings`), tested
|
|
10
|
+
// against the real file's shape: keep every block whose commands do not name our script,
|
|
11
|
+
// drop every block that does, append one canonical block per subscribed event. Other
|
|
12
|
+
// events and every other top-level key pass through untouched. Nothing is written when
|
|
13
|
+
// the merged object equals the current one.
|
|
14
|
+
//
|
|
15
|
+
// STABLE PATH. The script is copied from the package (`bin/hooks/cos-session-hook`) to
|
|
16
|
+
// `~/.cos-glasses/bin/cos-session-hook`, because the managed runtime's generation
|
|
17
|
+
// directory changes on every server update and a settings file pointing into it would
|
|
18
|
+
// break at the first Update Server. `status` reports `script_outdated` when the package
|
|
19
|
+
// copy differs from the installed one, and `install` re-copies.
|
|
20
|
+
|
|
21
|
+
import { copyFileSync, chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
22
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { dirname, join, resolve } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
|
|
30
|
+
/** Substring that marks a hook block as ours, whatever the home directory is. */
|
|
31
|
+
export const HOOK_SCRIPT_MARKER = '/.cos-glasses/bin/cos-session-hook'
|
|
32
|
+
export const HOOK_SCRIPT_NAME = 'cos-session-hook'
|
|
33
|
+
|
|
34
|
+
export interface HookSubscription {
|
|
35
|
+
event: string
|
|
36
|
+
matcher?: string
|
|
37
|
+
async: boolean
|
|
38
|
+
timeout: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every event the reducer understands, with the timing each one needs. SessionStart,
|
|
43
|
+
* UserPromptSubmit, Stop and SessionEnd are synchronous: measured 2026-09-15, an async
|
|
44
|
+
* Stop was dropped when a print-mode process exited right after it (PostToolUse landed,
|
|
45
|
+
* Stop did not), and those four carry the transitions a row is derived from (a dropped
|
|
46
|
+
* UserPromptSubmit would leave a running turn reading idle). The write is a few
|
|
47
|
+
* milliseconds. PermissionRequest must be synchronous to return a decision; the rest
|
|
48
|
+
* are fire-and-forget.
|
|
49
|
+
*/
|
|
50
|
+
export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
|
|
51
|
+
{ event: 'SessionStart', async: false, timeout: 5 },
|
|
52
|
+
{ event: 'SessionEnd', async: false, timeout: 5 },
|
|
53
|
+
{ event: 'UserPromptSubmit', async: false, timeout: 5 },
|
|
54
|
+
{ event: 'Stop', async: false, timeout: 5 },
|
|
55
|
+
{ event: 'StopFailure', async: true, timeout: 10 },
|
|
56
|
+
{ event: 'PermissionRequest', async: false, timeout: 130 },
|
|
57
|
+
{ event: 'PermissionDenied', async: true, timeout: 10 },
|
|
58
|
+
{ event: 'PreToolUse', matcher: 'AskUserQuestion|ExitPlanMode', async: true, timeout: 10 },
|
|
59
|
+
{ event: 'PostToolUse', async: true, timeout: 10 },
|
|
60
|
+
{ event: 'PostToolUseFailure', async: true, timeout: 10 },
|
|
61
|
+
{ event: 'Notification', matcher: 'permission_prompt|idle_prompt|elicitation_dialog|agent_needs_input', async: true, timeout: 10 },
|
|
62
|
+
{ event: 'SubagentStart', async: true, timeout: 10 },
|
|
63
|
+
{ event: 'SubagentStop', async: true, timeout: 10 },
|
|
64
|
+
{ event: 'PostCompact', async: true, timeout: 10 },
|
|
65
|
+
{ event: 'PostModelSwitch', async: true, timeout: 10 },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the token, port and spool live. Derived from `COS_DATA_DIR` when that is set, so
|
|
70
|
+
* a scratch server booted with a temp data dir never touches the production files under
|
|
71
|
+
* `~/.cos-glasses` (it did once, in review). `COS_GLASSES_HOME` overrides for tests.
|
|
72
|
+
*/
|
|
73
|
+
export function cosGlassesHome(): string {
|
|
74
|
+
if (process.env.COS_GLASSES_HOME) return resolve(process.env.COS_GLASSES_HOME)
|
|
75
|
+
if (process.env.COS_DATA_DIR) return dirname(resolve(process.env.COS_DATA_DIR))
|
|
76
|
+
return join(homedir(), '.cos-glasses')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The spool the server drains; baked into the installed command so the script agrees. */
|
|
80
|
+
export function hookSpoolDir(): string {
|
|
81
|
+
if (process.env.COS_SESSION_HOOKS_SPOOL_DIR) return resolve(process.env.COS_SESSION_HOOKS_SPOOL_DIR)
|
|
82
|
+
return join(process.env.COS_DATA_DIR ? resolve(process.env.COS_DATA_DIR) : join(cosGlassesHome(), 'data'), 'hook-spool')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function claudeSettingsPath(): string {
|
|
86
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ? resolve(process.env.CLAUDE_CONFIG_DIR) : join(homedir(), '.claude')
|
|
87
|
+
return join(configDir, 'settings.json')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function stableHookScriptPath(): string {
|
|
91
|
+
return join(cosGlassesHome(), 'bin', HOOK_SCRIPT_NAME)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The copy shipped in this package. */
|
|
95
|
+
export function packagedHookScriptPath(): string {
|
|
96
|
+
return resolve(__dirname, '..', '..', 'bin', 'hooks', HOOK_SCRIPT_NAME)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function hookTokenPath(): string { return join(cosGlassesHome(), 'hook-token') }
|
|
100
|
+
export function hookPortPath(): string { return join(cosGlassesHome(), 'hook-port') }
|
|
101
|
+
export function hookDeskIdlePath(): string { return join(cosGlassesHome(), 'hook-desk-idle-s') }
|
|
102
|
+
|
|
103
|
+
/** sh-safe quoting for the settings command string. Paths with spaces get single quotes. */
|
|
104
|
+
export function shellQuote(value: string): string {
|
|
105
|
+
return /^[A-Za-z0-9_\-./]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The installed command carries the two paths the script needs as environment, so the
|
|
110
|
+
* server and the script can never disagree about where the spool or the token is.
|
|
111
|
+
*/
|
|
112
|
+
export function hookCommand(scriptPath: string, event: string, paths: HookPaths = currentHookPaths()): string {
|
|
113
|
+
return `COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${event}`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface HookPaths {
|
|
117
|
+
home: string
|
|
118
|
+
spoolDir: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function currentHookPaths(): HookPaths {
|
|
122
|
+
return { home: cosGlassesHome(), spoolDir: hookSpoolDir() }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type HookBlock = { matcher?: unknown; hooks?: unknown }
|
|
126
|
+
|
|
127
|
+
function blockIsOurs(block: unknown): boolean {
|
|
128
|
+
if (!block || typeof block !== 'object') return false
|
|
129
|
+
const hooks = (block as HookBlock).hooks
|
|
130
|
+
if (!Array.isArray(hooks)) return false
|
|
131
|
+
return hooks.some(h => h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
|
|
132
|
+
&& ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' ')))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function canonicalBlock(scriptPath: string, sub: HookSubscription, paths: HookPaths): Record<string, unknown> {
|
|
136
|
+
const hook: Record<string, unknown> = { type: 'command', command: hookCommand(scriptPath, sub.event, paths), timeout: sub.timeout }
|
|
137
|
+
if (sub.async) hook.async = true
|
|
138
|
+
return sub.matcher ? { matcher: sub.matcher, hooks: [hook] } : { hooks: [hook] }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export type MergeResult =
|
|
142
|
+
| { ok: true; settings: Record<string, unknown>; changed: boolean }
|
|
143
|
+
/** The file holds a `hooks` shape this merge would have to destroy to proceed. */
|
|
144
|
+
| { ok: false; reason: 'settings_hooks_invalid' }
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Pure. Adds or refreshes our blocks; every foreign block and key survives in order. A
|
|
148
|
+
* `hooks` key that is not an object, or an event whose value is not an array, is refused
|
|
149
|
+
* rather than replaced: "preserving every existing hook block" is the contract.
|
|
150
|
+
*/
|
|
151
|
+
export function mergeHookSettings(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): MergeResult {
|
|
152
|
+
const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
153
|
+
const before = JSON.stringify(settings)
|
|
154
|
+
if (settings.hooks !== undefined && (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) return { ok: false, reason: 'settings_hooks_invalid' }
|
|
155
|
+
const hooksIn = (settings.hooks ?? {}) as Record<string, unknown>
|
|
156
|
+
const hooks: Record<string, unknown> = { ...hooksIn }
|
|
157
|
+
for (const sub of HOOK_SUBSCRIPTIONS) {
|
|
158
|
+
const value = hooks[sub.event]
|
|
159
|
+
if (value !== undefined && !Array.isArray(value)) return { ok: false, reason: 'settings_hooks_invalid' }
|
|
160
|
+
const existing = Array.isArray(value) ? value : []
|
|
161
|
+
const foreign = existing.filter(b => !blockIsOurs(b))
|
|
162
|
+
hooks[sub.event] = [...foreign, canonicalBlock(scriptPath, sub, paths)]
|
|
163
|
+
}
|
|
164
|
+
settings.hooks = hooks
|
|
165
|
+
return { ok: true, settings, changed: JSON.stringify(settings) !== before }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Pure. Removes our blocks; emptied event arrays are deleted, everything else survives. */
|
|
169
|
+
export function stripHookSettings(current: unknown): MergeResult {
|
|
170
|
+
const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
171
|
+
const before = JSON.stringify(settings)
|
|
172
|
+
const hooksIn = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks as Record<string, unknown> : null
|
|
173
|
+
if (!hooksIn) return { ok: true, settings, changed: false }
|
|
174
|
+
const hooks: Record<string, unknown> = {}
|
|
175
|
+
for (const [event, blocks] of Object.entries(hooksIn)) {
|
|
176
|
+
if (!Array.isArray(blocks)) { hooks[event] = blocks; continue }
|
|
177
|
+
const foreign = blocks.filter(b => !blockIsOurs(b))
|
|
178
|
+
if (foreign.length > 0) hooks[event] = foreign
|
|
179
|
+
}
|
|
180
|
+
settings.hooks = hooks
|
|
181
|
+
return { ok: true, settings, changed: JSON.stringify(settings) !== before }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Which of our events a settings object carries, by exact canonical command. */
|
|
185
|
+
export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[] } {
|
|
186
|
+
const hooks = current && typeof current === 'object' && (current as Record<string, unknown>).hooks
|
|
187
|
+
const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
|
|
188
|
+
const subscribed: string[] = []
|
|
189
|
+
const missing: string[] = []
|
|
190
|
+
const drifted: string[] = []
|
|
191
|
+
for (const sub of HOOK_SUBSCRIPTIONS) {
|
|
192
|
+
const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
|
|
193
|
+
const ours = blocks.filter(blockIsOurs)
|
|
194
|
+
if (ours.length === 0) { missing.push(sub.event); continue }
|
|
195
|
+
const canonical = JSON.stringify(canonicalBlock(scriptPath, sub, paths))
|
|
196
|
+
if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) subscribed.push(sub.event)
|
|
197
|
+
else drifted.push(sub.event)
|
|
198
|
+
}
|
|
199
|
+
return { subscribed, missing, drifted }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type HookInstallState =
|
|
203
|
+
| 'installed'
|
|
204
|
+
| 'drift'
|
|
205
|
+
| 'missing'
|
|
206
|
+
| 'script_outdated'
|
|
207
|
+
/** The user-level file carries `disableAllHooks: true`: installed or not, nothing fires. */
|
|
208
|
+
| 'disabled_by_settings'
|
|
209
|
+
| 'settings_unparseable'
|
|
210
|
+
| 'settings_symlink'
|
|
211
|
+
/** The file exists but could not be read (EACCES, ELOOP, ENOTDIR): not "missing". */
|
|
212
|
+
| 'settings_unreadable'
|
|
213
|
+
|
|
214
|
+
export interface HookStatus {
|
|
215
|
+
state: HookInstallState
|
|
216
|
+
/** True only for `installed`: the boolean Control's banner keys off. */
|
|
217
|
+
installed: boolean
|
|
218
|
+
settingsPath: string
|
|
219
|
+
scriptPath: string
|
|
220
|
+
scriptSha: string | null
|
|
221
|
+
packageScriptSha: string | null
|
|
222
|
+
subscribed: string[]
|
|
223
|
+
missing: string[]
|
|
224
|
+
drifted: string[]
|
|
225
|
+
tokenPresent: boolean
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sha256File(path: string): string | null {
|
|
229
|
+
try { return createHash('sha256').update(readFileSync(path)).digest('hex') } catch { return null }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
type SettingsRead = { ok: true; settings: unknown; existed: boolean } | { ok: false; reason: 'settings_unparseable' | 'settings_symlink' | 'settings_unreadable' }
|
|
233
|
+
|
|
234
|
+
function readSettings(path: string): SettingsRead {
|
|
235
|
+
try {
|
|
236
|
+
if (lstatSync(path).isSymbolicLink()) return { ok: false, reason: 'settings_symlink' }
|
|
237
|
+
} catch (error) {
|
|
238
|
+
// Only "no such file" is missing. Anything else is a file that could not be looked at,
|
|
239
|
+
// and "I could not look" must never render as "it is not there".
|
|
240
|
+
if ((error as { code?: string }).code === 'ENOENT') return { ok: true, settings: {}, existed: false }
|
|
241
|
+
return { ok: false, reason: 'settings_unreadable' }
|
|
242
|
+
}
|
|
243
|
+
let text: string
|
|
244
|
+
try { text = readFileSync(path, 'utf-8') } catch { return { ok: false, reason: 'settings_unreadable' } }
|
|
245
|
+
try {
|
|
246
|
+
return { ok: true, settings: JSON.parse(text), existed: true }
|
|
247
|
+
} catch {
|
|
248
|
+
return { ok: false, reason: 'settings_unparseable' }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hooksDisabled(settings: unknown): boolean {
|
|
253
|
+
return !!settings && typeof settings === 'object' && (settings as { disableAllHooks?: unknown }).disableAllHooks === true
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function hookStatus(paths: { settingsPath?: string; scriptPath?: string; packageScriptPath?: string; hookPaths?: HookPaths } = {}): HookStatus {
|
|
257
|
+
const settingsPath = paths.settingsPath ?? claudeSettingsPath()
|
|
258
|
+
const scriptPath = paths.scriptPath ?? stableHookScriptPath()
|
|
259
|
+
const packageScriptPath = paths.packageScriptPath ?? packagedHookScriptPath()
|
|
260
|
+
const hookPaths = paths.hookPaths ?? currentHookPaths()
|
|
261
|
+
const scriptSha = sha256File(scriptPath)
|
|
262
|
+
const packageScriptSha = sha256File(packageScriptPath)
|
|
263
|
+
const tokenPresent = existsSync(hookTokenPath())
|
|
264
|
+
const read = readSettings(settingsPath)
|
|
265
|
+
if (!read.ok) {
|
|
266
|
+
return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], tokenPresent }
|
|
267
|
+
}
|
|
268
|
+
const events = subscribedEvents(read.settings, scriptPath, hookPaths)
|
|
269
|
+
let state: HookInstallState
|
|
270
|
+
if (hooksDisabled(read.settings)) state = 'disabled_by_settings'
|
|
271
|
+
else if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
|
|
272
|
+
else if (events.missing.length > 0 || events.drifted.length > 0) state = 'drift'
|
|
273
|
+
else if (!scriptSha || (packageScriptSha && scriptSha !== packageScriptSha)) state = 'script_outdated'
|
|
274
|
+
else state = 'installed'
|
|
275
|
+
return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export interface InstallOptions {
|
|
279
|
+
settingsPath?: string
|
|
280
|
+
scriptPath?: string
|
|
281
|
+
packageScriptPath?: string
|
|
282
|
+
hookPaths?: HookPaths
|
|
283
|
+
port?: number
|
|
284
|
+
deskIdleSeconds?: number
|
|
285
|
+
dryRun?: boolean
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface InstallResult {
|
|
289
|
+
ok: boolean
|
|
290
|
+
reason?: string
|
|
291
|
+
changed: boolean
|
|
292
|
+
scriptCopied: boolean
|
|
293
|
+
backupPath: string | null
|
|
294
|
+
status: HookStatus
|
|
295
|
+
/** The merged settings, for `--dry-run` to print. */
|
|
296
|
+
merged?: Record<string, unknown>
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const BACKUPS_KEPT = 3
|
|
300
|
+
|
|
301
|
+
function backupSettings(settingsPath: string): string | null {
|
|
302
|
+
if (!existsSync(settingsPath)) return null
|
|
303
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
304
|
+
const backup = `${settingsPath}.cos-backup-${stamp}`
|
|
305
|
+
try {
|
|
306
|
+
copyFileSync(settingsPath, backup)
|
|
307
|
+
chmodSync(backup, 0o600)
|
|
308
|
+
} catch { return null }
|
|
309
|
+
try {
|
|
310
|
+
const dir = dirname(settingsPath)
|
|
311
|
+
const prefix = `${settingsPath.slice(dir.length + 1)}.cos-backup-`
|
|
312
|
+
const olds = readdirSync(dir).filter(n => n.startsWith(prefix)).sort()
|
|
313
|
+
for (const old of olds.slice(0, Math.max(0, olds.length - BACKUPS_KEPT))) {
|
|
314
|
+
try { unlinkSync(join(dir, old)) } catch { /* fine */ }
|
|
315
|
+
}
|
|
316
|
+
} catch { /* the backup itself succeeded */ }
|
|
317
|
+
return backup
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Mint the per-install token once (0600, atomically); write port and desk-idle files every time. */
|
|
321
|
+
export function ensureHookRuntimeFiles(port: number, deskIdleSeconds: number): void {
|
|
322
|
+
const home = cosGlassesHome()
|
|
323
|
+
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
324
|
+
if (!existsSync(hookTokenPath())) atomicWriteFileSync(hookTokenPath(), randomBytes(32).toString('hex'), { mode: 0o600 })
|
|
325
|
+
atomicWriteFileSync(hookPortPath(), String(port), { mode: 0o600 })
|
|
326
|
+
atomicWriteFileSync(hookDeskIdlePath(), String(Math.max(0, Math.trunc(deskIdleSeconds))), { mode: 0o600 })
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function readHookToken(): string | null {
|
|
330
|
+
try { return readFileSync(hookTokenPath(), 'utf-8').trim() || null } catch { return null }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Copy the packaged script to the stable path when it differs (`install`), or only when it
|
|
335
|
+
* is missing (`onlyIfMissing`, used at boot: an older server booting must never downgrade
|
|
336
|
+
* the script a newer `install` put there, since every Claude session on the Mac runs it).
|
|
337
|
+
* Returns true when copied.
|
|
338
|
+
*/
|
|
339
|
+
export function ensureStableHookScript(scriptPath = stableHookScriptPath(), packageScriptPath = packagedHookScriptPath(), onlyIfMissing = false): boolean {
|
|
340
|
+
const want = sha256File(packageScriptPath)
|
|
341
|
+
if (!want) throw new Error(`hook script missing from the package at ${packageScriptPath}`)
|
|
342
|
+
const have = sha256File(scriptPath)
|
|
343
|
+
if (have === want) return false
|
|
344
|
+
if (have && onlyIfMissing) return false
|
|
345
|
+
mkdirSync(dirname(scriptPath), { recursive: true, mode: 0o755 })
|
|
346
|
+
atomicWriteFileSync(scriptPath, readFileSync(packageScriptPath), { mode: 0o755 })
|
|
347
|
+
chmodSync(scriptPath, 0o755)
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function installClaudeHooks(options: InstallOptions = {}): InstallResult {
|
|
352
|
+
const settingsPath = options.settingsPath ?? claudeSettingsPath()
|
|
353
|
+
const scriptPath = options.scriptPath ?? stableHookScriptPath()
|
|
354
|
+
const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
|
|
355
|
+
const hookPaths = options.hookPaths ?? currentHookPaths()
|
|
356
|
+
const read = readSettings(settingsPath)
|
|
357
|
+
const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths })
|
|
358
|
+
const refuse = (reason: string, scriptCopied = false): InstallResult => ({ ok: false, reason, changed: false, scriptCopied, backupPath: null, status: statusNow() })
|
|
359
|
+
if (!read.ok) return refuse(read.reason)
|
|
360
|
+
const merged = mergeHookSettings(read.settings, scriptPath, hookPaths)
|
|
361
|
+
if (!merged.ok) return refuse(merged.reason)
|
|
362
|
+
if (options.dryRun) {
|
|
363
|
+
return { ok: true, changed: merged.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: merged.settings }
|
|
364
|
+
}
|
|
365
|
+
let scriptCopied = false
|
|
366
|
+
try {
|
|
367
|
+
scriptCopied = ensureStableHookScript(scriptPath, packageScriptPath)
|
|
368
|
+
ensureHookRuntimeFiles(options.port ?? 3141, options.deskIdleSeconds ?? 90)
|
|
369
|
+
} catch (error) {
|
|
370
|
+
return refuse(error instanceof Error ? error.message : String(error), scriptCopied)
|
|
371
|
+
}
|
|
372
|
+
let backupPath: string | null = null
|
|
373
|
+
if (merged.changed) {
|
|
374
|
+
if (read.existed) {
|
|
375
|
+
backupPath = backupSettings(settingsPath)
|
|
376
|
+
// No backup, no overwrite: "with a backup of the file" is the contract.
|
|
377
|
+
if (!backupPath) return refuse('backup_failed', scriptCopied)
|
|
378
|
+
}
|
|
379
|
+
mkdirSync(dirname(settingsPath), { recursive: true, mode: 0o700 })
|
|
380
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(merged.settings, null, 2) + '\n', { mode: 0o600 })
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, changed: merged.changed, scriptCopied, backupPath, status: statusNow() }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function uninstallClaudeHooks(options: Pick<InstallOptions, 'settingsPath' | 'scriptPath' | 'packageScriptPath' | 'hookPaths' | 'dryRun'> = {}): InstallResult {
|
|
386
|
+
const settingsPath = options.settingsPath ?? claudeSettingsPath()
|
|
387
|
+
const scriptPath = options.scriptPath ?? stableHookScriptPath()
|
|
388
|
+
const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
|
|
389
|
+
const hookPaths = options.hookPaths ?? currentHookPaths()
|
|
390
|
+
const read = readSettings(settingsPath)
|
|
391
|
+
const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths })
|
|
392
|
+
if (!read.ok) return { ok: false, reason: read.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
393
|
+
const stripped = stripHookSettings(read.settings)
|
|
394
|
+
if (!stripped.ok) return { ok: false, reason: stripped.reason, changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
395
|
+
if (options.dryRun) return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: stripped.settings }
|
|
396
|
+
let backupPath: string | null = null
|
|
397
|
+
if (stripped.changed) {
|
|
398
|
+
backupPath = backupSettings(settingsPath)
|
|
399
|
+
if (!backupPath) return { ok: false, reason: 'backup_failed', changed: false, scriptCopied: false, backupPath: null, status: statusNow() }
|
|
400
|
+
atomicWriteFileSync(settingsPath, JSON.stringify(stripped.settings, null, 2) + '\n', { mode: 0o600 })
|
|
401
|
+
}
|
|
402
|
+
return { ok: true, changed: stripped.changed, scriptCopied: false, backupPath, status: statusNow() }
|
|
403
|
+
}
|
|
@@ -43,6 +43,7 @@ export interface RawClaudeSession {
|
|
|
43
43
|
status?: unknown
|
|
44
44
|
updatedAt?: unknown
|
|
45
45
|
waitingFor?: unknown
|
|
46
|
+
statusUpdatedAt?: unknown
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
export interface ClaudePeer {
|
|
@@ -63,6 +64,30 @@ export interface ClaudePeer {
|
|
|
63
64
|
startedAt: number | null
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* A peer plus the facts the state deriver needs and the wire must not carry: the full
|
|
69
|
+
* session id (the wire shortens it on purpose) and when `status` last moved. Built by
|
|
70
|
+
* `readClaudePeerRecords`; `readClaudePeers` strips it back to the wire shape.
|
|
71
|
+
*/
|
|
72
|
+
export interface ClaudePeerRecord extends ClaudePeer {
|
|
73
|
+
sessionId: string
|
|
74
|
+
pid: number
|
|
75
|
+
statusUpdatedAt: number | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function peerRecordFacts(raw: RawClaudeSession): { sessionId: string; pid: number; statusUpdatedAt: number | null } | null {
|
|
79
|
+
const pid = Number(raw.pid)
|
|
80
|
+
const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId : ''
|
|
81
|
+
if (!Number.isInteger(pid) || pid <= 0 || !sessionId) return null
|
|
82
|
+
return { sessionId: sessionId.toLowerCase(), pid, statusUpdatedAt: millis(raw.statusUpdatedAt) }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The wire shape and nothing else: a record is never serialized as is. */
|
|
86
|
+
export function toWirePeer(record: ClaudePeerRecord): ClaudePeer {
|
|
87
|
+
const { sessionId: _sessionId, pid: _pid, statusUpdatedAt: _statusUpdatedAt, ...peer } = record
|
|
88
|
+
return peer
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
export interface PeerProbes {
|
|
67
92
|
/** signal-0 liveness. EPERM (another user) must resolve to false, not true. */
|
|
68
93
|
isAlive: (pid: number) => boolean
|
|
@@ -182,6 +182,37 @@ export function sidecarSessionId(monthDir: string, meetingFilename: string): str
|
|
|
182
182
|
return sidecarListHints(monthDir, meetingFilename).sessionId
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
// ── Merged-scribe markers (6.47.0, apply mode) ───────────────────────────────
|
|
186
|
+
//
|
|
187
|
+
// On a Mac with the COS pipeline the merge is spliced INTO the existing Fireflies
|
|
188
|
+
// scribe rather than written as a new record, so there is no `.derived.json` to
|
|
189
|
+
// read and the scribe itself has to say what it holds. The pipeline writes four
|
|
190
|
+
// markers (WS8 step 4); these two are the ones a row identity needs.
|
|
191
|
+
//
|
|
192
|
+
// WHY THE SCRIBE AND NOT A SIDECAR. A merged scribe replaced a Fireflies scribe,
|
|
193
|
+
// which never had a `.g2-chunks.json`. Its G2 sidecar stays beside the retired
|
|
194
|
+
// standalone capture, so the only place that says "this file holds session X" is
|
|
195
|
+
// the file.
|
|
196
|
+
|
|
197
|
+
/** `<!-- g2-session: <sessionId> -->`, once per G2 capture the scribe holds. */
|
|
198
|
+
export const G2_SESSION_MARKER = /<!--\s*g2-session:\s*([A-Za-z0-9:_-]{3,96})\s*-->/g
|
|
199
|
+
/** `<!-- merge-action: <actionId> -->`, so Control can offer the merge's Undo. */
|
|
200
|
+
export const MERGE_ACTION_MARKER = /<!--\s*merge-action:\s*([A-Za-z0-9_-]{1,64})\s*-->/
|
|
201
|
+
|
|
202
|
+
/** Every session a merged scribe declares, in the order it declares them. */
|
|
203
|
+
export function mergedScribeSessions(content: string): string[] {
|
|
204
|
+
const sessions: string[] = []
|
|
205
|
+
for (const match of content.matchAll(G2_SESSION_MARKER)) {
|
|
206
|
+
if (!sessions.includes(match[1])) sessions.push(match[1])
|
|
207
|
+
}
|
|
208
|
+
return sessions
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The action a merged scribe was written by, when it declares one. */
|
|
212
|
+
export function mergedScribeActionId(content: string): string | undefined {
|
|
213
|
+
return content.match(MERGE_ACTION_MARKER)?.[1]
|
|
214
|
+
}
|
|
215
|
+
|
|
185
216
|
function envPath(name: string): string | null {
|
|
186
217
|
const raw = process.env[name]?.trim()
|
|
187
218
|
if (!raw) return null
|
|
@@ -439,6 +470,14 @@ function withMeetingListInsights(meta: CosOperationsMeetingMeta, content: string
|
|
|
439
470
|
*
|
|
440
471
|
* Scans sidecar heads rather than parsing them, so the cost is a 4 KB read per
|
|
441
472
|
* candidate and not the transcript.
|
|
473
|
+
*
|
|
474
|
+
* A MERGED SCRIBE OUTRANKS AN ORPHAN SIDECAR (6.47.0). Applying a merge splices
|
|
475
|
+
* the capture into the Fireflies scribe and then RETIRES the G2 standalone
|
|
476
|
+
* scribe, leaving its `.g2-chunks.json` behind with no markdown beside it. The
|
|
477
|
+
* chunks are still the evidence — that sidecar is what the speaker review reads —
|
|
478
|
+
* but the readable meeting is now the merged scribe, so this returns the orphan's
|
|
479
|
+
* sidecar path with the merged scribe's path, filename and title. Without that,
|
|
480
|
+
* naming a voice on a merged meeting opened a file that no longer existed.
|
|
442
481
|
*/
|
|
443
482
|
export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
444
483
|
sidecarPath: string
|
|
@@ -468,17 +507,29 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
468
507
|
for (const sidecarName of sidecars) {
|
|
469
508
|
const meetingFilename = sidecarName.replace(/\.g2-chunks\.json$/, '.md')
|
|
470
509
|
if (sidecarSessionId(monthDir, meetingFilename) !== sessionId) continue
|
|
471
|
-
const
|
|
472
|
-
let
|
|
510
|
+
const sidecarPath = join(monthDir, sidecarName)
|
|
511
|
+
let meetingPath = join(monthDir, meetingFilename)
|
|
512
|
+
let resolvedFilename = meetingFilename
|
|
513
|
+
let content: string | null = null
|
|
473
514
|
try {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
515
|
+
content = readFileSync(meetingPath, 'utf-8')
|
|
516
|
+
} catch {
|
|
517
|
+
// The capture's own scribe is gone. Only now is it worth reading the
|
|
518
|
+
// month's other scribes to find the one that declares this session.
|
|
519
|
+
const merged = findMergedScribeForSession(monthDir, sessionId)
|
|
520
|
+
if (merged) {
|
|
521
|
+
resolvedFilename = merged.filename
|
|
522
|
+
meetingPath = join(monthDir, merged.filename)
|
|
523
|
+
content = merged.content
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
let title = resolvedFilename.replace(/\.md$/, '')
|
|
527
|
+
const heading = content?.slice(0, 4000).match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
528
|
+
if (heading) title = heading
|
|
478
529
|
return {
|
|
479
|
-
sidecarPath
|
|
530
|
+
sidecarPath,
|
|
480
531
|
meetingPath,
|
|
481
|
-
filename:
|
|
532
|
+
filename: resolvedFilename,
|
|
482
533
|
domain,
|
|
483
534
|
month,
|
|
484
535
|
title,
|
|
@@ -489,6 +540,32 @@ export function findCosOperationsMeetingBySessionId(sessionId: string): {
|
|
|
489
540
|
return null
|
|
490
541
|
}
|
|
491
542
|
|
|
543
|
+
/**
|
|
544
|
+
* The scribe in this month that declares `sessionId` through `<!-- g2-session -->`.
|
|
545
|
+
*
|
|
546
|
+
* Only ever called when a capture's own scribe is missing, so the full-content
|
|
547
|
+
* read it costs lands on a path that was about to answer with a dead file.
|
|
548
|
+
*/
|
|
549
|
+
function findMergedScribeForSession(
|
|
550
|
+
monthDir: string,
|
|
551
|
+
sessionId: string,
|
|
552
|
+
): { filename: string; content: string } | null {
|
|
553
|
+
let names: string[]
|
|
554
|
+
try {
|
|
555
|
+
names = readdirSync(monthDir).filter(name => name.endsWith('.md')).sort().reverse()
|
|
556
|
+
} catch {
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
for (const filename of names.slice(0, MAX_LIST_CANDIDATES)) {
|
|
560
|
+
let content: string
|
|
561
|
+
try {
|
|
562
|
+
content = readFileSync(join(monthDir, filename), 'utf-8')
|
|
563
|
+
} catch { continue }
|
|
564
|
+
if (mergedScribeSessions(content).includes(sessionId)) return { filename, content }
|
|
565
|
+
}
|
|
566
|
+
return null
|
|
567
|
+
}
|
|
568
|
+
|
|
492
569
|
export type MeetingLibraryRecord = NonNullable<ReturnType<typeof findCosOperationsMeetingBySessionId>> & {
|
|
493
570
|
recordId?: string
|
|
494
571
|
librarySource?: 'direct_library' | 'cos_operations'
|
|
@@ -588,6 +665,20 @@ export function listCosOperationsMeetings(options: {
|
|
|
588
665
|
if (hints.speakers.length > 0) {
|
|
589
666
|
meta.voiceReview = meetingVoiceReview(hints.speakers, hints.sessionId)
|
|
590
667
|
}
|
|
668
|
+
// A scribe the pipeline merged says so in its own body: it holds
|
|
669
|
+
// captures whose standalone rows this list must then drop, and it
|
|
670
|
+
// names the action Control offers Undo for. The content is already
|
|
671
|
+
// in memory, so this costs no extra read. The sidecar wins for
|
|
672
|
+
// `sessionId` when both are present — the sidecar is the file the
|
|
673
|
+
// speaker system keys on.
|
|
674
|
+
const declared = mergedScribeSessions(content)
|
|
675
|
+
if (declared.length > 0) {
|
|
676
|
+
meta.derivedKind = 'merge'
|
|
677
|
+
meta.g2SessionIds = declared
|
|
678
|
+
if (!meta.sessionId) meta.sessionId = declared[0]
|
|
679
|
+
const actionId = mergedScribeActionId(content)
|
|
680
|
+
if (actionId) meta.actionId = actionId
|
|
681
|
+
}
|
|
591
682
|
if (options.day && meta.date !== options.day) continue
|
|
592
683
|
allMeetings.push(meta)
|
|
593
684
|
} catch { /* skip unreadable files */ }
|