@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +24 -0
  3. package/bin/cli.cjs +22 -0
  4. package/bin/hooks/cos-session-hook +43 -0
  5. package/managed-runtime-contract.json +7 -1
  6. package/package.json +8 -2
  7. package/server/index.ts +85 -0
  8. package/server/lib/claude-hooks-installer.ts +403 -0
  9. package/server/lib/claude-session-registry.ts +25 -0
  10. package/server/lib/cos-operations-meetings.ts +99 -8
  11. package/server/lib/fireflies-client.ts +862 -0
  12. package/server/lib/fireflies-key.ts +182 -0
  13. package/server/lib/imported-library-rows.ts +616 -0
  14. package/server/lib/imported-meeting-library.ts +608 -0
  15. package/server/lib/maintenance-lifecycle.ts +14 -0
  16. package/server/lib/meeting-actions-store.ts +478 -0
  17. package/server/lib/meeting-actions.ts +2583 -0
  18. package/server/lib/meeting-corrections.ts +32 -1
  19. package/server/lib/meeting-decisions.ts +223 -0
  20. package/server/lib/meeting-engine/align.ts +167 -0
  21. package/server/lib/meeting-engine/attribute.ts +265 -0
  22. package/server/lib/meeting-engine/evidence.ts +428 -0
  23. package/server/lib/meeting-engine/pairing.ts +327 -0
  24. package/server/lib/meeting-engine/render.ts +694 -0
  25. package/server/lib/meeting-engine/split.ts +242 -0
  26. package/server/lib/meeting-engine/worker.ts +238 -0
  27. package/server/lib/meeting-engine-mode.ts +197 -0
  28. package/server/lib/meeting-file-guards.ts +141 -0
  29. package/server/lib/meeting-import.ts +763 -0
  30. package/server/lib/meeting-library-search.ts +146 -11
  31. package/server/lib/meeting-parse.ts +184 -0
  32. package/server/lib/meeting-store.ts +108 -275
  33. package/server/lib/meeting-suggestion-sides.ts +242 -0
  34. package/server/lib/morning-brief-runtime.ts +20 -8
  35. package/server/lib/pipeline-runner.ts +227 -0
  36. package/server/lib/session-hook-events.ts +200 -0
  37. package/server/lib/session-hook-ledger.ts +129 -0
  38. package/server/lib/session-hook-spool.ts +264 -0
  39. package/server/lib/session-hooks-runtime.ts +229 -0
  40. package/server/lib/session-signal-store.ts +361 -0
  41. package/server/lib/session-state-derive.ts +211 -0
  42. package/server/lib/voice-evidence-guard.ts +87 -0
  43. package/server/routes/agent-sessions.ts +56 -6
  44. package/server/routes/claude-sessions.ts +32 -5
  45. package/server/routes/fireflies-key.ts +102 -0
  46. package/server/routes/health.ts +2 -0
  47. package/server/routes/meeting-actions.ts +82 -0
  48. package/server/routes/meeting-engine.ts +52 -0
  49. package/server/routes/meeting-import.ts +67 -0
  50. package/server/routes/meeting-suggestions.ts +66 -0
  51. package/server/routes/meeting.ts +117 -10
  52. package/server/routes/meetings.ts +177 -50
  53. package/server/routes/session-hooks.ts +70 -0
  54. package/server/routes/voice.ts +18 -0
  55. package/server/scripts/hooks-cli.ts +48 -0
  56. package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
package/CHANGELOG.md CHANGED
@@ -1,3 +1,28 @@
1
+ ## 6.48.0
2
+
3
+ Session state now comes from Claude Code itself. A hook script spools every session event (session start and end, prompt submitted, turn stopped, permission wanted) and every session row carries one derived state with its provenance. No client change is required: Control 0.5.230 and glasses 6.9.478 ignore the new keys; Control 0.5.231 and the next EHPK read them.
4
+
5
+ - `--hooks install|status|uninstall [--dry-run] [--port N]` writes fifteen hook subscriptions into `~/.claude/settings.json` (user level, so Desktop tabs, the CLI and `claude -p` jobs all report), preserving every existing hook block and every other key, with a backup of the file and an atomic 0600 write. The script is copied to the stable path `~/.cos-glasses/bin/cos-session-hook` so a server update never breaks it; `status` reports `script_outdated` when the package copy moved on and `install` re-copies. The same actions are on `POST /api/session-hooks/install` and `/uninstall` for COS Control. `status` also reports `disabled_by_settings` (the file carries `disableAllHooks`) and `settings_unreadable`, and `serverApplies` says whether the running server would read the spool into rows at all. A hooks install changes sessions started after it; tabs already open keep their startup snapshot until restarted. Removing the package does not remove the hooks: run `--hooks uninstall` first.
6
+ - The hook is a POSIX sh script that writes one file per event into `~/.cos-glasses/data/hook-spool` and exits 0 on every path; it never talks to the server, so a session is never delayed by a server restart. It stops spooling when the spool holds 2,000 files or the server has not drained for a day, except permission requests. Only `PermissionRequest` may contact the server, and only after the desk has been idle (`ioreg` HIDIdleTime, default 90 s), for the broker that ships in 6.48.2; on 6.48.0 that call is refused (401) and the hook prints nothing, so the native dialog shows as before. An unreadable idle time never contacts the server.
7
+ - The server ingests the spool (2 s sweep, `fs.watch` as a wake-up, bounded batches), keeps a projected ledger at `~/.cos-glasses/data/session-hook-events.jsonl` (10 MB rotation, replayed for the last 6 hours at boot), and reduces the events into one record per session. A pending permission clears only on evidence for that request (its own PostToolUse by fingerprint, a PermissionDenied naming the tool, Stop, a new prompt, SessionEnd; a parallel auto-allowed tool of the same name ending does not clear it); a Continue child that shares the tab's session id never changes the tab's state; a dead registry pid on two consecutive scans ends the row.
8
+ - Rows on `/api/agent-sessions` (list and detail) and peers on `/api/claude-sessions` gain `agent_state` (running, waiting, idle, failed, ended), `state_source` (hook, registry, transcript), `state_since`, and when known `waiting_kind` (permission, question, plan, mcp_input), `waiting_detail` (the tool and its target), `failure`, and `last_reply` (the turn's last assistant message, 400 chars; the ledger keeps the same 400 chars of each prompt). `pending_permission_id` is reserved for the 6.48.2 broker and never set here. `state` keeps its old meaning and values. Older clients ignore the new keys.
9
+ - `/api/health` gains `sessionHooks` (enabled, state, installed, scriptSha, tokenPresent, lastEventAt, spoolBacklog, spoolStuck, applyErrors, ledgerBytes, ledgerError and spoolError as codes, signals). A pending wait is also cleared by evidence the hooks cannot see: the registry moving on after it, the transcript moving on well after it, or nothing alive standing behind it for 30 minutes; an interrupted turn (no Stop fires) follows the registry to idle at once. `GET /api/session-hooks/runs?since=<ms>` lists the sessions the hooks saw start and end, for Control's scheduled-job ledger.
10
+ - Flags: `COS_SESSION_HOOKS` (default on whenever `COS_CLAUDE_SESSIONS_ENABLED=1`; off still drains and stamps the spool and every row is exactly as 6.47.0), `COS_SESSION_HOOKS_SPOOL_DIR` (baked into the installed hook command together with the token directory, so the script and the server always agree; reinstall after changing it, `status` reports `drift` until then), `managed-runtime-contract.json` lists all six, and the reserved `COS_SESSION_HOOK_SSE`, `COS_PERMISSION_BROKER`, `COS_PERMISSION_BROKER_DESK_IDLE_S`, `COS_PERMISSION_BROKER_TIMEOUT_S` for the next releases. The next Control (0.5.231) adds all six to its runtime environment allowlist; until then a hand-set flag is dropped by Control's next Update Server, which is the fail-safe direction.
11
+ - Fixtures under `server/lib/__fixtures__/session-hooks-6.48.0/` are real 2.1.272 recordings (paths redacted) and are excluded from the npm tarball. Recorded there: `SessionStart.source`, `SessionEnd.reason`, a `Stop` with no stop reason, a `PermissionRequest` with no `tool_use_id`, two permission hooks in one turn running one after the other, and a hook deny whose message reaches Claude with no `PermissionDenied` afterwards. Measured limit: an asynchronous hook that fires right before a print-mode process exits can be dropped by Claude (a `PostToolUse` in one recording, a `Stop` in another), so SessionStart, UserPromptSubmit, Stop and SessionEnd (the four events a row's state turns on) are installed synchronously with a 5 s timeout and were never dropped that way. The script itself is behaviour-tested under its own restricted PATH: spool naming, the 1 MiB cap, both B8 guards, the empty-token and closed-port paths, and a PermissionRequest round trip against a local listener.
12
+
13
+ ## 6.47.0
14
+
15
+ Meetings from Fireflies join the library, and recordings of the same meeting merge into one richer record. Paired Control build: 0.5.230. Glasses 6.9.474 adds Ref on the lens and needs no server change.
16
+
17
+ - Fireflies import, on Macs without the COS pipeline. Paste a Fireflies API key in COS Control; the server checks it and reports a typed state (ok, invalid key, rate limited, vendor down, unreachable, no key). Imports arrive through the Fireflies API only, never from the desktop app's files, with a 7, 30 or 90 day backfill that resumes across the plan's daily limit, a 6 hour poll that uses at most half of the day's budget, and a ledger that reconciles what the vendor listed against what was written. Every failure has its own state; a run that imported nothing and failed reads as the failure. Imported meetings carry Fireflies' overview and action items.
18
+ - A deterministic merge engine, on a worker thread. It pairs a G2 recording with the Fireflies meeting that shares the most unique three-word phrases at a consistent time offset, refuses a pairing whose clocks disagree by more than 15 minutes, collapses duplicate recordings of one meeting, and groups every G2 recording of a meeting into one merged record. No language model is involved anywhere in import, pairing, alignment, splitting or merging.
19
+ - Merges are additive. A merged record holds the Fireflies transcript, the full G2 capture, any alternate transcript, the union of attendees, the vendor summary and action items, and a Sources table. Where the recordings align, G2 voice matches label generic Fireflies speakers, shown with their score, and the original label is kept. Source files are never modified.
20
+ - Every automatic action can be undone. High confidence merges apply automatically with Undo; an undone merge is never repeated by automation. Lower confidence pairings become suggestions to confirm or dismiss. Recordings made before the engine was installed only ever become suggestions. Content-anchored splits of long recordings apply automatically with Revert on a Mac WITHOUT the COS pipeline, where a split writes new records; on a Mac with the pipeline a split is only ever a suggestion, because there is no additive, revertible way to turn one operations scribe into three, and accepting one in apply mode is refused with `split_not_supported_in_apply_mode`.
21
+ - Macs with the COS pipeline start in advise mode: the engine shows would-merge items and suggestions and writes nothing into the operations tree. Switching to apply in Control hands each decision to sync_meetings.py, which archives the original to a unique verified path, splices the merge into the existing scribe (title, summary, decisions and action items survive), and can revert byte for byte. One mode file is read by every pipeline process; there is no environment flag.
22
+ - Lists, detail, search, day counts and speaker review understand imported and merged records. A merged row replaces the rows it merged; its sources stay reachable from its detail. Imported and merged records are read only for speaker mutations, and never train voice profiles.
23
+ - New routes: fireflies-key set, status, check and delete; meeting-import fireflies run, status and settings; meeting-suggestions list, accept, confirm and dismiss; meeting-actions list, one by id, revert, retry and revert-all; meeting-engine status and mode. A failed merge or undo is not terminal: retry puts it back in its own direction with a fresh attempt budget. Every suggestion carries both sides resolved server-side (title, start, length, source and the record that still holds each one), so a pipeline Mac can read them. The contract is in docs/meeting-merge-contract.md.
24
+ - Compatibility: COS Control 0.5.229 and older show these features as unavailable and keep everything else. Glasses 6.9.473 lists merged meetings under an older source label. Older servers ignore the imports folder entirely. Rollback: run revert-all first if merged records should not remain, then set the mode back to advise before downgrading.
25
+
1
26
  ## 6.46.1
2
27
 
3
28
  G2 recordings show in a multi-folder Meetings library that has no COS pipeline to file them. No Control or glasses update is needed.
package/README.md CHANGED
@@ -165,6 +165,30 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
165
165
  `COS_WEATHER_DEFAULT_*`, otherwise omits weather. Optional `nextEvent`
166
166
  appears when `COS_SCRIPTS_DIR` calendar data is available.
167
167
 
168
+ ## Session hooks (6.48.0)
169
+
170
+ Claude Code can tell the server what each session is doing (started, prompt submitted,
171
+ waiting on a permission, turn stopped, ended) through its own hooks. Install them once:
172
+
173
+ ```bash
174
+ npx --yes @gotcos/glasses-server@latest --hooks install --dry-run # shows the merge, writes nothing
175
+ npx --yes @gotcos/glasses-server@latest --hooks install # merges into ~/.claude/settings.json
176
+ npx --yes @gotcos/glasses-server@latest --hooks status
177
+ npx --yes @gotcos/glasses-server@latest --hooks uninstall
178
+ ```
179
+
180
+ The install keeps every hook you already had, backs the file up, and copies a small
181
+ POSIX sh script to `~/.cos-glasses/bin/cos-session-hook`. The script writes one file per
182
+ event into `~/.cos-glasses/data/hook-spool` and never contacts the server (a permission
183
+ request may, only after the desk has been idle for 90 s, for the approval feature that ships
184
+ next). Sessions started after the install report `state_source: hook` on
185
+ `/api/agent-sessions` and `/api/claude-sessions`; tabs already open keep their startup
186
+ hook snapshot until restarted, and may show Claude's "hooks modified externally" notice once (expected: the user-level file changed under them). The next COS Control (0.5.231) offers the same install from
187
+ its Sessions tab. Rows change only while the server runs with `COS_CLAUDE_SESSIONS_ENABLED=1`
188
+ (`--hooks status` prints `serverApplies`). Turn the ingestion off with `COS_SESSION_HOOKS=0`
189
+ (the spool is still drained and stamped, rows are exactly as before). Removing the package
190
+ does not remove the hooks: run `--hooks uninstall` first.
191
+
168
192
  ## Configuration
169
193
 
170
194
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
package/bin/cli.cjs CHANGED
@@ -27,6 +27,7 @@ const CONFIG_DIR = join(homedir(), '.cos-glasses')
27
27
  const PREPARE_ONLY = process.argv.includes('--prepare-only')
28
28
  const SETUP_TRANSCRIPTION = process.argv.includes('--setup-transcription')
29
29
  const SETUP_SPEAKER_MODEL = process.argv.includes('--setup-speaker-model')
30
+ const HOOKS_ACTION = process.argv.includes('--hooks') ? process.argv[process.argv.indexOf('--hooks') + 1] : undefined
30
31
  function optionValue(name) {
31
32
  const index = process.argv.indexOf(name)
32
33
  return index >= 0 ? process.argv[index + 1] : undefined
@@ -145,6 +146,26 @@ function setupSpeakerModel() {
145
146
  return 0
146
147
  }
147
148
 
149
+ // --hooks install|status|uninstall: the Claude Code session hook (6.48.0). Runs before the
150
+ // banner and the runtime checks, resolving tsx itself; the managed runtime never comes
151
+ // through here (Control uses POST /api/session-hooks/install on the running server).
152
+ if (process.argv.includes('--hooks')) {
153
+ if (!['install', 'status', 'uninstall'].includes(HOOKS_ACTION)) {
154
+ console.error('Usage: --hooks install|status|uninstall [--dry-run] [--port N]')
155
+ process.exit(64)
156
+ }
157
+ let hooksTsx
158
+ try {
159
+ hooksTsx = require.resolve('tsx/esm', { paths: [PKG_ROOT] })
160
+ } catch {
161
+ console.error('COS package dependencies are incomplete; run npm install in the package first.')
162
+ process.exit(2)
163
+ }
164
+ const rest = process.argv.slice(process.argv.indexOf('--hooks') + 1)
165
+ const child = require('child_process').spawnSync(process.execPath, ['--import', hooksTsx, join(PKG_ROOT, 'server', 'scripts', 'hooks-cli.ts'), ...rest], { cwd: PKG_ROOT, stdio: 'inherit' })
166
+ process.exit(child.status ?? 1)
167
+ }
168
+
148
169
  if (SETUP_SPEAKER_MODEL) {
149
170
  console.log('')
150
171
  console.log(bold(' COS Glasses - voiceprint model'))
@@ -162,6 +183,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
162
183
  console.log(' npx --yes @gotcos/glasses-server@latest --setup-transcription --transcription-tier balanced|max')
163
184
  console.log(' npx --yes @gotcos/glasses-server@latest --setup-speaker-model')
164
185
  console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
186
+ console.log(' npx --yes @gotcos/glasses-server@latest --hooks install|status|uninstall [--dry-run] [--port N]')
165
187
  console.log('')
166
188
  console.log(' Requirements:')
167
189
  console.log(' - Node.js 20.11+')
@@ -0,0 +1,43 @@
1
+ #!/bin/sh
2
+ # COS session hook (glasses-server 6.48.0). Installed into ~/.claude/settings.json by
3
+ # `--hooks install`; Claude Code runs it with the hook event name as $1 and the event
4
+ # payload on stdin. It spools one JSON file per event into the hook spool and exits 0
5
+ # on every path. Only PermissionRequest ever talks to the server, and only when the
6
+ # desk has been idle. Everything else is a file write, so the server being down or
7
+ # restarting never delays a Claude session.
8
+ #
9
+ # The installer bakes COS_GLASSES_HOME and COS_HOOK_SPOOL into the command, so this
10
+ # script and the server can never disagree about where the token or the spool is.
11
+ PATH=/usr/bin:/bin:/usr/sbin; umask 077; trap '' PIPE
12
+ HOME=${HOME:-$(cd ~ 2>/dev/null && pwd)}; [ -n "$HOME" ] || exit 0
13
+ GH=${COS_GLASSES_HOME:-$HOME/.cos-glasses}
14
+ SPOOL=${COS_HOOK_SPOOL:-$GH/data/hook-spool}; EVENT="$1"
15
+ mkdir -p "$SPOOL/rejected" 2>/dev/null || exit 0
16
+ TS=$(perl -MTime::HiRes -e 'printf "%.0f", Time::HiRes::time()*1000' 2>/dev/null || date +%s000)
17
+ # B8 guard: a full spool or a stale drain stamp means nobody is reading; keep only permissions.
18
+ if [ "$EVENT" != PermissionRequest ]; then
19
+ N=$(ls "$SPOOL" 2>/dev/null | wc -l | tr -d ' '); [ "${N:-0}" -lt 2000 ] || exit 0
20
+ STAMP="$SPOOL/.last-drain"; [ ! -f "$STAMP" ] || [ -z "$(find "$STAMP" -mmin +1440 2>/dev/null)" ] || exit 0 # -mtime +1 would mean two days
21
+ fi
22
+ T="$SPOOL/.tmp.$$.$TS"; head -c 1048576 > "$T.in" # stdin to disk, 1 MiB cap
23
+ case "$(head -c 1 "$T.in")" in '{') ;; *) EVENT=rejected ;; esac
24
+ { printf '{"ts":%s,"ppid":%s,"event":"%s","payload":' "$TS" "$PPID" "$EVENT"; cat "$T.in"; printf '}\n'; } > "$T"
25
+ rm -f "$T.in"
26
+ if [ "$EVENT" != PermissionRequest ]; then mv "$T" "$SPOOL/$TS-$$-$EVENT.json"; exit 0; fi # rename = publish
27
+ # A permission request keeps its own copy: the server unlinks the published file within
28
+ # milliseconds of the rename, and the request body below must outlive that.
29
+ cp "$T" "$T.req" 2>/dev/null; mv "$T" "$SPOOL/$TS-$$-$EVENT.json"
30
+ cleanup() { rm -f "$T.req"; }
31
+ # Desk-active fast path decided HERE (about 30 ms): no HTTP at all when someone is at the
32
+ # keyboard. An unreadable idle time fails the same way: nothing is contacted, the native
33
+ # dialog shows.
34
+ IDLE_NS=$(ioreg -c IOHIDSystem 2>/dev/null | awk -F'= ' '/HIDIdleTime/{print $2; exit}')
35
+ DESK_S=$(cat "$GH/hook-desk-idle-s" 2>/dev/null || echo 90)
36
+ { [ -n "$IDLE_NS" ] && [ "$(( ${IDLE_NS%.*} / 1000000000 ))" -ge "$DESK_S" ]; } || { cleanup; exit 0; }
37
+ TOK=$(cat "$GH/hook-token" 2>/dev/null); [ -n "$TOK" ] || { cleanup; exit 0; } # no token, no request
38
+ PORT=$(cat "$GH/hook-port" 2>/dev/null || echo 3141)
39
+ OUT=$(curl -s --connect-timeout 2 --max-time 125 -H "X-Cos-Hook-Token: $TOK" -H 'content-type: application/json' \
40
+ --data-binary @"$T.req" "http://127.0.0.1:$PORT/api/permission-requests/ask") || { cleanup; exit 0; }
41
+ cleanup
42
+ case "$OUT" in '{"hookSpecificOutput"'*) printf '%s' "$OUT" ;; esac
43
+ exit 0
@@ -26,7 +26,13 @@
26
26
  "COS_UNSAVED_AUDIO_RETENTION_HOURS",
27
27
  "COS_CLAUDE_TRUST_MODE",
28
28
  "COS_CODEX_SANDBOX",
29
- "COS_SCRIPTS_DIR"
29
+ "COS_SCRIPTS_DIR",
30
+ "COS_SESSION_HOOKS",
31
+ "COS_SESSION_HOOKS_SPOOL_DIR",
32
+ "COS_SESSION_HOOK_SSE",
33
+ "COS_PERMISSION_BROKER",
34
+ "COS_PERMISSION_BROKER_DESK_IDLE_S",
35
+ "COS_PERMISSION_BROKER_TIMEOUT_S"
30
36
  ],
31
37
  "maintenance": {
32
38
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.46.1",
3
+ "version": "6.48.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "start": "node bin/cli.cjs",
13
13
  "start:server": "node --import tsx/esm server/index.ts",
14
14
  "test": "vitest run --maxWorkers=1",
15
- "typecheck": "tsc --noEmit"
15
+ "typecheck": "tsc --noEmit",
16
+ "mutation:gate": "node server/scripts/mutation-gate.mjs"
16
17
  },
17
18
  "keywords": [
18
19
  "even-g2",
@@ -28,6 +29,7 @@
28
29
  "files": [
29
30
  "bin/cli.cjs",
30
31
  "bin/managed-server.cjs",
32
+ "bin/hooks/cos-session-hook",
31
33
  "managed-runtime-contract.json",
32
34
  "server",
33
35
  "!server/data/**",
@@ -36,6 +38,10 @@
36
38
  "server/models/silero_vad.onnx",
37
39
  "shared",
38
40
  "!server/**/*.test.ts",
41
+ "!server/scripts/engine-canary.ts",
42
+ "!server/scripts/mutation-gate.mjs",
43
+ "!server/lib/meeting-engine/__fixtures__/**",
44
+ "!server/lib/__fixtures__/**",
39
45
  "!shared/**/*.test.ts",
40
46
  ".env.example",
41
47
  ".cos-profile.example.json",
package/server/index.ts CHANGED
@@ -22,6 +22,8 @@ import { agentSessionsRouter } from './routes/agent-sessions.js'
22
22
  import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
23
23
  import { createAttachedTurnStream } from './lib/session-stream-producer.js'
24
24
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
25
+ import { createSessionHooksRouter } from './routes/session-hooks.js'
26
+ import { startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
25
27
  import {
26
28
  createAgentSessionBindingsRouter,
27
29
  TargetGuard,
@@ -43,6 +45,20 @@ import { meetingRouter, resumeMeetingFinalizationJobs } from './routes/meeting.j
43
45
  import { meetingsRouter } from './routes/meetings.js'
44
46
  import { openaiCompatRouter } from './routes/openai-compat.js'
45
47
  import { openaiKeyRouter } from './routes/openai-key.js'
48
+ import { firefliesKeyRouter } from './routes/fireflies-key.js'
49
+ import { meetingImportRouter } from './routes/meeting-import.js'
50
+ import { startMeetingImportScheduler, stopMeetingImportScheduler } from './lib/meeting-import.js'
51
+ import { meetingSuggestionsRouter } from './routes/meeting-suggestions.js'
52
+ import { meetingActionsRouter } from './routes/meeting-actions.js'
53
+ import { meetingEngineRouter } from './routes/meeting-engine.js'
54
+ import {
55
+ redrivePendingMergeActions,
56
+ sweepOrphanDecisions,
57
+ startMeetingMergeScheduler,
58
+ stopMeetingMergeScheduler,
59
+ } from './lib/meeting-actions.js'
60
+ import { getImportedMeetingLibrary } from './lib/imported-meeting-library.js'
61
+ import { maintenanceLifecycle } from './lib/maintenance-lifecycle.js'
46
62
  import { messageRefRouter } from './routes/message-ref.js'
47
63
  import { archiveRouter } from './routes/archive.js'
48
64
  import { sessionsRouter } from './routes/sessions.js'
@@ -220,6 +236,18 @@ app.use('/api', (req, res, next) => {
220
236
  || req.path === '/transcribe'
221
237
  || req.path.startsWith('/transcribe-stream')
222
238
  || req.path === '/meeting/save'
239
+ // The import run answers 202 and keeps working. It takes its own
240
+ // maintenance lease per page and checks admissions itself, so the global
241
+ // request lease would only double-own the same work.
242
+ || req.path === '/meeting-import/fireflies/run'
243
+ // The suggestion and action mutations queue onto the merge runner, which takes its
244
+ // own `meeting_merge` lease PER ACTION and catches a drain by deferring that action.
245
+ // The global request lease would double-own the same work, and in apply mode it
246
+ // would hold across a 75s pipeline spawn — past COS Control's 90s drain timeout.
247
+ // Each of these still refuses on its own when admissions are closed.
248
+ || req.path.startsWith('/meeting-suggestions/')
249
+ || req.path.startsWith('/meeting-actions/')
250
+ || req.path === '/meeting-engine/mode'
223
251
  || req.path.startsWith('/prompt-drafts')
224
252
  || req.path.startsWith('/maintenance/drain')
225
253
  if (lifecycleOwned) return next()
@@ -356,6 +384,11 @@ const attachedWorkspaceDeps = realAttachedWorkspaceDeps(nativeHeadDeps)
356
384
  */
357
385
  const occupancyProbes = buildOccupancyProbes(cosSpawnedPids, nativeHeadDeps, threadAttachEnabled())
358
386
 
387
+ // 6.48.0: the hook spool ingester and the one signal store every session row reads. Starts
388
+ // before any router is registered so the first list request already sees the replayed
389
+ // ledger. With COS_SESSION_HOOKS off it still drains and stamps the spool (see the module).
390
+ const sessionHooksRuntime = startSessionHooksRuntime({ port: PORT })
391
+
359
392
  /**
360
393
  * The shim between the route's request shape and the adapter's.
361
394
  *
@@ -511,6 +544,7 @@ app.use('/api', agentSessionStreamRouter)
511
544
  // Presence view of Claude Code sessions on this Mac. Dark unless
512
545
  // COS_CLAUDE_SESSIONS_ENABLED=1 — it projects another product's 0700 state dir.
513
546
  app.use('/api', claudeSessionsRouter)
547
+ app.use('/api', createSessionHooksRouter({ port: PORT }))
514
548
  // Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
515
549
  // without colliding with a live writer? Read-only — it answers, it never attaches.
516
550
  // Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
@@ -675,6 +709,13 @@ app.use('/api', memoryRouter)
675
709
  app.use('/api', threadsRouter)
676
710
  app.use('/api', skillsRouter)
677
711
  app.use('/api', openaiKeyRouter)
712
+ // Fireflies: the key, and importing meetings this Mac did not record (6.47.0).
713
+ app.use('/api', firefliesKeyRouter)
714
+ app.use('/api', meetingImportRouter)
715
+ // The merge engine: what it wants decided, what it did, and its own state (6.47.0).
716
+ app.use('/api', meetingSuggestionsRouter)
717
+ app.use('/api', meetingActionsRouter)
718
+ app.use('/api', meetingEngineRouter)
678
719
  // v6.3.0 — Message History, cross-day 'reference message N', and history
679
720
  // recovery for public npx users (previously full-COS-server only).
680
721
  app.use('/api', messageRefRouter)
@@ -725,6 +766,9 @@ async function gracefulShutdown(): Promise<void> {
725
766
  const forceExit = setTimeout(() => process.exit(1), 8_000)
726
767
  forceExit.unref?.()
727
768
  stopMorningBriefScheduler()
769
+ stopMeetingImportScheduler()
770
+ stopMeetingMergeScheduler()
771
+ sessionHooksRuntime.stop()
728
772
  try {
729
773
  await shutdownQueryJobRuntime('server_shutdown')
730
774
  } catch (error) {
@@ -915,6 +959,47 @@ listenRequiredServers(listeners).then(() => {
915
959
  // was interrupted by a prior server update or process exit.
916
960
  resumeMeetingFinalizationJobs()
917
961
 
962
+ // Debris from an import killed mid-write: a temp file, or a sidecar whose
963
+ // markdown never landed. Both are invisible to every reader, so nothing
964
+ // would ever surface them. Skipped while a writer holds the lease, because
965
+ // mid-write that sidecar is the correct intermediate state.
966
+ try {
967
+ const swept = getImportedMeetingLibrary().sweep({
968
+ isBusy: () => (maintenanceLifecycle.snapshot().activeByKind.meeting_import ?? 0) > 0,
969
+ })
970
+ if (swept.removedTemp > 0 || swept.removedSidecars > 0) {
971
+ console.log(`[imports] startup sweep: ${swept.removedTemp} temp file(s), ${swept.removedSidecars} orphan sidecar(s)`)
972
+ }
973
+ } catch (error) {
974
+ console.warn('[imports] startup sweep failed', error)
975
+ }
976
+
977
+ // The Fireflies poll. Its own tick refuses in advise mode, without a key,
978
+ // while admissions are closed, and past its share of the daily budget, so
979
+ // starting it here is safe in every configuration.
980
+ startMeetingImportScheduler()
981
+
982
+ // The merge engine's own 30s tick (a full pass every six hours), and the
983
+ // central speaker-correction hook that re-derives affected records. Both
984
+ // defer while a capture is live or admissions are closed.
985
+ startMeetingMergeScheduler()
986
+
987
+ // AFTER the imports sweep, not before: the sweep removes a killed write's
988
+ // debris, and re-driving a pending apply into a half-written decision is
989
+ // exactly what the sweep exists to prevent. A pending action is one a
990
+ // previous process wrote and did not finish; the pipeline steps are
991
+ // idempotent, so re-driving one that actually completed is a no-op.
992
+ void redrivePendingMergeActions()
993
+ .then(count => { if (count > 0) console.log(`[meeting-merge] re-drove ${count} waiting action(s)`) })
994
+ .then(() => {
995
+ // A decision file is written BEFORE its action row, so a crash in that window
996
+ // leaves transcript text nothing will ever read or delete. Runs after the
997
+ // re-drive, so an action being driven right now still owns its decision.
998
+ const removed = sweepOrphanDecisions()
999
+ if (removed > 0) console.log(`[meeting-merge] removed ${removed} orphan decision file(s)`)
1000
+ })
1001
+ .catch(error => console.warn('[meeting-merge] pending re-drive failed', error))
1002
+
918
1003
  void initQueryJobRuntime().then(health => {
919
1004
  if (process.env.COS_DURABLE_QUERY_JOBS !== '0') {
920
1005
  console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)