@gotcos/glasses-server 6.44.15 → 6.45.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 (55) hide show
  1. package/CHANGELOG.md +83 -49
  2. package/README.md +9 -0
  3. package/bin/memory-setup.cjs +55 -0
  4. package/memory-runtime/cos-memory-runtime-0.1.0.tar.gz +0 -0
  5. package/memory-runtime/runtime.json +7 -0
  6. package/package.json +6 -3
  7. package/server/index.ts +4 -4
  8. package/server/lib/adaptive-playback-audio.ts +1 -1
  9. package/server/lib/agent-session-store.ts +139 -28
  10. package/server/lib/archive-search.ts +1 -1
  11. package/server/lib/attached-provider-adapter.ts +1 -1
  12. package/server/lib/attached-workspace.ts +3 -3
  13. package/server/lib/chunk-embedding-store.ts +1 -1
  14. package/server/lib/context-files.ts +1 -1
  15. package/server/lib/cos-context-browser.ts +17 -6
  16. package/server/lib/cos-operations-meetings.ts +15 -1
  17. package/server/lib/domains.ts +2 -2
  18. package/server/lib/maintenance-lifecycle.ts +1 -1
  19. package/server/lib/meeting-audio-archive.ts +3 -3
  20. package/server/lib/meeting-corrections.ts +3 -3
  21. package/server/lib/meeting-relabel-enrolment.ts +1 -1
  22. package/server/lib/meeting-relabel.ts +2 -2
  23. package/server/lib/meeting-scribe-content.ts +7 -7
  24. package/server/lib/meeting-speaker-review.ts +3 -3
  25. package/server/lib/meeting-summary.ts +1 -1
  26. package/server/lib/message-era-reset.ts +1 -1
  27. package/server/lib/message-era.ts +1 -1
  28. package/server/lib/morning-brief-config.ts +1 -1
  29. package/server/lib/morning-brief-coverage.ts +1 -1
  30. package/server/lib/occupancy-probes.ts +7 -4
  31. package/server/lib/ollama-tools.ts +1 -1
  32. package/server/lib/python-bridge.ts +6 -4
  33. package/server/lib/query-job-types.ts +1 -1
  34. package/server/lib/session-stream-events.ts +3 -3
  35. package/server/lib/session-transcript-watcher.ts +1 -1
  36. package/server/lib/speaker-calibration-log.ts +1 -1
  37. package/server/lib/speaker-name.ts +1 -1
  38. package/server/lib/thread-occupancy.ts +1 -1
  39. package/server/lib/thread-turn-queue-deliver.ts +1 -1
  40. package/server/lib/thread-turn-queue.ts +3 -3
  41. package/server/lib/video-upload-v2.ts +1 -1
  42. package/server/lib/voice-profile-store.ts +2 -2
  43. package/server/lib/whisper-metal-gate.ts +1 -1
  44. package/server/routes/agent-session-bindings.ts +2 -2
  45. package/server/routes/agent-session-stream.ts +1 -1
  46. package/server/routes/agent-sessions.ts +2 -2
  47. package/server/routes/claude-sessions.ts +1 -1
  48. package/server/routes/health.ts +1 -1
  49. package/server/routes/meeting.ts +8 -8
  50. package/server/routes/memory.ts +45 -7
  51. package/server/routes/prompt-drafts.ts +1 -1
  52. package/server/routes/session-index.ts +1 -1
  53. package/server/routes/thread-turn-queue.ts +10 -4
  54. package/server/routes/transcribe-stream.ts +1 -1
  55. package/server/routes/voice.ts +4 -4
@@ -89,7 +89,7 @@ export const meetingSummaryBreaker = breaker
89
89
  * environment on update, and tests flip this per-case; a module-scope const
90
90
  * would freeze the value at import and make both untestable.
91
91
  *
92
- * DEFAULT ON since 6.37.0 (Miles 2026-08-25). Shipping this off meant the
92
+ * DEFAULT ON since 6.37.0 (the user 2026-08-25). Shipping this off meant the
93
93
  * headline fix — a meeting that comes back with a real summary — reached
94
94
  * nobody unless they found an undocumented env var.
95
95
  *
@@ -90,7 +90,7 @@ export async function resetLiveMessageEra(input: MessageEraResetInput): Promise<
90
90
  //
91
91
  // This used to endSession() every live session before rotating, which is what
92
92
  // made "reset the message count" also empty CHAT and kill the conversation the
93
- // wearer was in the middle of. Miles wants the opposite shape: the next message
93
+ // wearer was in the middle of. the user wants the opposite shape: the next message
94
94
  // is #1, the old cards keep their numbers, and the thread survives.
95
95
  //
96
96
  // Numbers stay unique because they are {messageEra, globalMsgNum}, not a bare
@@ -1,4 +1,4 @@
1
- // Message-number eras let Miles start again at #1 without deleting history.
1
+ // Message-number eras let the user start again at #1 without deleting history.
2
2
  // Old sessions/day archives remain immutable and browseable; the current era
3
3
  // alone owns short voice references such as "reference message 4".
4
4
  //
@@ -12,7 +12,7 @@
12
12
  // and connectors it already has. So a "source" here is an instruction the
13
13
  // composer turns into one section of the prompt, and a user personalises the
14
14
  // brief by choosing sources and their windows rather than by editing prose.
15
- // Miles (2026-09-01): "the user should be able to define the different sources
15
+ // the user (2026-09-01): "the user should be able to define the different sources
16
16
  // that will be pulled into their brief so that it's as useful as possible."
17
17
 
18
18
  import { chmodSync, mkdirSync } from 'node:fs'
@@ -3,7 +3,7 @@
3
3
  // The Sources list names an INSTRUCTION ("Meetings", "Knowledge graph"); the
4
4
  // server can also say how deep the well is behind it: 2,312 meetings stored,
5
5
  // 6,705 memories and 66 threads, the /good-morning skill found under
6
- // .claude/skills. Miles (2026-09-01): "I'm assuming we should see all of these
6
+ // .claude/skills. the user (2026-09-01): "I'm assuming we should see all of these
7
7
  // stats when we turn it on." Those are the same numbers COS Control's Activity
8
8
  // tiles show, read from the same places, so the card agrees with the tiles.
9
9
  //
@@ -86,8 +86,11 @@ export function realOccupancyDirs(): OccupancyDirs {
86
86
  const PS_BIN = existsSync('/bin/ps') ? '/bin/ps' : 'ps'
87
87
  const LSOF_BIN = existsSync('/usr/sbin/lsof') ? '/usr/sbin/lsof' : 'lsof'
88
88
 
89
- /** Both probes sit in the interactive attach path. lsof can block for seconds on a stale mount. */
89
+ /** Both probes sit in the interactive attach path and remain hard bounded. */
90
90
  const PROBE_TIMEOUT_MS = 2_000
91
+ // A whole-process descriptor scan measured ~2 s on a healthy busy Mac. Give
92
+ // lsof its own budget; timeout still refuses attachment rather than assuming idle.
93
+ const LOCK_PROBE_TIMEOUT_MS = 5_000
91
94
  const PROBE_MAX_BUFFER = 1 << 20
92
95
 
93
96
  /** A registry record is a few hundred bytes. Anything larger is not one. */
@@ -147,11 +150,11 @@ export interface ProbeOutcome {
147
150
  spawnError: string | null
148
151
  }
149
152
 
150
- function runProbe(bin: string, args: string[]): ProbeOutcome {
153
+ function runProbe(bin: string, args: string[], timeoutMs = PROBE_TIMEOUT_MS): ProbeOutcome {
151
154
  try {
152
155
  const stdout = execFileSync(bin, args, {
153
156
  encoding: 'utf8',
154
- timeout: PROBE_TIMEOUT_MS,
157
+ timeout: timeoutMs,
155
158
  killSignal: 'SIGKILL',
156
159
  maxBuffer: PROBE_MAX_BUFFER,
157
160
  env: probeEnv(),
@@ -355,7 +358,7 @@ export function lockHolders(path: string): number[] {
355
358
  // `--` terminates option parsing so a path can never be read as a flag; `-w`
356
359
  // suppresses mount-point warnings that would otherwise make a successful probe
357
360
  // look like the failure case below.
358
- return interpretLockHolders(runProbe(LSOF_BIN, ['-w', '-t', '--', path]), path)
361
+ return interpretLockHolders(runProbe(LSOF_BIN, ['-w', '-t', '--', path], LOCK_PROBE_TIMEOUT_MS), path)
359
362
  }
360
363
 
361
364
  /**
@@ -166,7 +166,7 @@ function capHits<T>(payload: Record<string, unknown>, hits: T[]): string {
166
166
  * it found nothing.
167
167
  *
168
168
  * Empty hits plus a reason describe THIS call. Without the reason a model reads
169
- * an empty list as proof the archive is empty and tells Miles he has no
169
+ * an empty list as proof the archive is empty and tells the user he has no
170
170
  * meetings. `semanticReason` is `"none"` rather than absent so the field can
171
171
  * never be silently missing.
172
172
  */
@@ -67,6 +67,7 @@ export const LEARNING_COMMANDS = [
67
67
  'graph-ingest-progress',
68
68
  'graph-setup-embedding',
69
69
  'graph-setup-extraction',
70
+ 'memory-workspace',
70
71
  'memory-review',
71
72
  'memory-guardrails',
72
73
  'memory-guardrails-run',
@@ -116,9 +117,9 @@ if (pythonAvailable) {
116
117
  * pipeline is configured; otherwise resolves to an empty/no-op result so the
117
118
  * context builder degrades gracefully on a standalone install.
118
119
  */
119
- export function callPython(args: string[], timeoutMs = 30_000, input?: string): Promise<unknown> {
120
+ export function callPython(args: string[], timeoutMs = 30_000, input?: string, signal?: AbortSignal): Promise<unknown> {
120
121
  if (pythonAvailable) {
121
- return callPythonDirect(args, timeoutMs, input)
122
+ return callPythonDirect(args, timeoutMs, input, signal)
122
123
  }
123
124
  return Promise.resolve(standaloneNoop(args))
124
125
  }
@@ -257,6 +258,7 @@ function standaloneNoop(args: string[]): unknown {
257
258
  case 'graph-ingest-progress':
258
259
  case 'graph-setup-embedding':
259
260
  case 'graph-setup-extraction':
261
+ case 'memory-workspace':
260
262
  case 'memory-review':
261
263
  case 'memory-guardrails':
262
264
  case 'memory-guardrails-run':
@@ -285,12 +287,12 @@ function argLimit(args: string[], fallback: number): number {
285
287
  }
286
288
 
287
289
  /** Full Python bridge — requires the user's venv + cos_api_bridge.py. */
288
- function callPythonDirect(args: string[], timeoutMs: number, input?: string): Promise<unknown> {
290
+ function callPythonDirect(args: string[], timeoutMs: number, input?: string, signal?: AbortSignal): Promise<unknown> {
289
291
  return new Promise((resolvePromise, reject) => {
290
292
  const child = execFile(
291
293
  PYTHON_BIN!,
292
294
  [BRIDGE_SCRIPT!, ...args],
293
- { cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
295
+ { cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024, signal },
294
296
  (err, stdout, stderr) => {
295
297
  if (err) {
296
298
  // The bridge prints its unknown-command answer to STDOUT and exits 1
@@ -55,7 +55,7 @@ export interface QueryJobPromptReference {
55
55
  /**
56
56
  * Who STARTED a job, when it was not the person holding the phone or the
57
57
  * glasses. A label, nothing more: the server never infers anything from its
58
- * absence (Miles, 2026-09-02). `routine` is the scheduler (`morning-brief`),
58
+ * absence (the user, 2026-09-02). `routine` is the scheduler (`morning-brief`),
59
59
  * `task` is a dispatch of a captured task (the id is the task's 12-hex id).
60
60
  * Human prompts typed on the phone carry no origin. The phone stamps `g2`
61
61
  * locally for prompts spoken on the glasses, as a bare string; if that string
@@ -25,7 +25,7 @@ export type SessionStreamState = 'working' | 'idle' | 'done'
25
25
 
26
26
  export type SessionStreamDraft =
27
27
  | { kind: 'tool'; verb: SessionStreamVerb; target: string; detail: string }
28
- // The user's own words for the turn being worked on. Miles: "we should see the query
28
+ // The user's own words for the turn being worked on. the user: "we should see the query
29
29
  // that the user has versus it just being a blank slate where it says working. That
30
30
  // way, the user at least knows what the agent is actively working on."
31
31
  //
@@ -145,7 +145,7 @@ function countLines(value: unknown): number {
145
145
  /**
146
146
  * The part of a shell command worth 40 columns.
147
147
  *
148
- * WHAT WENT WRONG ON HARDWARE. Miles's 9:20 screenshot showed `bash ses...` and
148
+ * WHAT WENT WRONG ON HARDWARE. the user's 9:20 screenshot showed `bash ses...` and
149
149
  * `bash s...` -- a shell command reduced to two characters. Two causes compounding:
150
150
  * the raw command was sent whole, and the CLIENT then treated it as a PATH and kept
151
151
  * only the text after the last `/`. So `cd /Users/.../cos-glasses-app && grep -n x
@@ -347,7 +347,7 @@ function draftsFromClaudeRecord(record: Record<string, unknown>): SessionStreamD
347
347
  //
348
348
  // This used to drop both, on the reasoning that "the prompt came from this device".
349
349
  // That is true of a Continue turn and FALSE of the case that matters most: a session
350
- // running in a Mac window, where the user row is the question Miles typed there and
350
+ // running in a Mac window, where the user row is the question the user typed there and
351
351
  // the glasses have never seen it. Dropping it is what made the live view a blank
352
352
  // slate that said WORKING and nothing else.
353
353
  //
@@ -176,7 +176,7 @@ async function readRangeAt(path: string, offset: number, length: number): Promis
176
176
  * The last complete records before a byte offset, for the OPEN-time seed.
177
177
  *
178
178
  * WHY THIS EXISTS. The tail starts at the file's current size, so opening a session
179
- * that is already working showed an EMPTY page that filled one line at a time. Miles,
179
+ * that is already working showed an EMPTY page that filled one line at a time. the user,
180
180
  * on hardware: "the live session pulling is a little lackluster." You walk up to the
181
181
  * desk and the monitor is blank. The history is right there in the file; the tailer was
182
182
  * simply choosing not to read it.
@@ -29,7 +29,7 @@ export interface CalibrationPurgeResult {
29
29
  *
30
30
  * Pure so the matching rule can be tested without touching the filesystem.
31
31
  * Matching is exact on the `speaker` field only: a substring match would delete
32
- * every "Miles Mallard" row when removing "Miles", and the name also appears in
32
+ * every "Alex Other" row when removing "the user", and the name also appears in
33
33
  * no other field.
34
34
  */
35
35
  export function filterCalibrationRows(
@@ -1,6 +1,6 @@
1
1
  // What may become a voice-profile name.
2
2
  //
3
- // WHY THIS EXISTS (Chelsie Hodgkiss, first-time user, 2026-08-25)
3
+ // WHY THIS EXISTS (Casey Example, first-time user, 2026-08-25)
4
4
  // Saying "enroll my voice" and continuing to talk produced a profile whose
5
5
  // NAME was the entire ~40-second transcript. Two runs, two junk profiles, one
6
6
  // embedding each. Because no junk name ever equals owner_speaker_label,
@@ -491,7 +491,7 @@ export interface OccupancyDirs {
491
491
  //
492
492
  // WHY THE OLD RULE WAS WRONG. The registry records an OPEN WINDOW, not active
493
493
  // generation. `~/.claude/sessions/<pid>.json` for a session that finished ten
494
- // minutes ago is byte-identical to one mid-turn. Miles keeps Claude Code
494
+ // minutes ago is byte-identical to one mid-turn. the user keeps Claude Code
495
495
  // windows open, so Continue was refused for exactly the threads he cares about
496
496
  // and allowed only for the ones he had abandoned. The gate was measuring the
497
497
  // wrong thing, not measuring it too strictly.
@@ -17,7 +17,7 @@
17
17
  // against a current baseline, which is what the user meant by queueing it: "append this
18
18
  // to whatever the thread is when it frees". An interactive turn still gets the full
19
19
  // divergence check, because there the user composed against a state they were looking
20
- // at and a silent change is genuinely surprising. Miles approved this explicitly.
20
+ // at and a silent change is genuinely surprising. the user approved this explicitly.
21
21
  //
22
22
  // FAILS CLOSED, AND AMBIGUITY IS A FAILURE. Anything other than a clean admission
23
23
  // resolves `ok: false`, which returns the turn to the queue rather than marking it
@@ -3,7 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  // WHY
5
5
  // ---------------------------------------------------------------------------
6
- // Miles, 2026-08-17, looking at a thread he could not continue: "This thread, the
6
+ // the user, 2026-08-17, looking at a thread he could not continue: "This thread, the
7
7
  // COS-glasses server, has actually completed, but it's still locked. Ideally, what would
8
8
  // happen is if there's a session that's still running, that would just put it into the
9
9
  // queue the same way that the user has the ability to do so." His example was his own
@@ -31,7 +31,7 @@
31
31
  // when it can never run is worse than refusing.
32
32
  //
33
33
  // ---------------------------------------------------------------------------
34
- // THE WATERMARK EXEMPTION -- Miles approved this explicitly
34
+ // THE WATERMARK EXEMPTION -- the user approved this explicitly
35
35
  // ---------------------------------------------------------------------------
36
36
  // A binding carries a content watermark, and a write is refused with
37
37
  // `native_thread_changed` when the thread's head digest moved since the turn was
@@ -46,7 +46,7 @@
46
46
  // divergence check, because there the user composed against a state they were looking
47
47
  // at and a silent change is genuinely surprising.
48
48
  //
49
- // Miles, asked directly before this was built: "Yeah, this is a fine exemption."
49
+ // the user, asked directly before this was built: "Yeah, this is a fine exemption."
50
50
  //
51
51
  // ---------------------------------------------------------------------------
52
52
  // WHAT MAKES A TURN READY, AND WHY NOT "IDLE"
@@ -54,7 +54,7 @@ const CLIENT_REQUEST_RE = /^[A-Za-z0-9._:-]{16,160}$/
54
54
  const MEDIA_ID_RE = /^m_[0-9a-f]{24}$/
55
55
 
56
56
  export function videoUploadV2Enabled(): boolean {
57
- // Default ON since 6.37.0 (Miles 2026-08-25). Absent key = on; only a
57
+ // Default ON since 6.37.0 (the user 2026-08-25). Absent key = on; only a
58
58
  // literal '0' disables. NOTE: an in-flight upload sets blocksRestart, so a
59
59
  // drain caught mid-upload waits for it — that is the intended contract, not
60
60
  // a stuck gate, and must never be --forced.
@@ -124,7 +124,7 @@ export function normalizeProfileStore(raw: unknown): { store: ProfileStore; repa
124
124
  ? (candidate as VoiceProfile).name.trim()
125
125
  : ''
126
126
  if (!rawName) { repairs.profilesDropped++; continue }
127
- // Chelsie's store (2026-09-08) held two profiles named with the entire
127
+ // Casey's store (2026-09-08) held two profiles named with the entire
128
128
  // enrolment speech (~600 characters), written by a client older than the
129
129
  // 6.8.433 / server 8/25 guards. A name like that can never match a speaker
130
130
  // label, and the file cannot be repaired by hand because the server
@@ -445,7 +445,7 @@ export function profileCentroid(embeddings: number[][]): number[] {
445
445
  }
446
446
 
447
447
  /** How close two profiles' centroids are. This is the number a merge must be
448
- * justified by: names are not evidence, and `Miles Mallard` / `Manoj Kumar`
448
+ * justified by: names are not evidence, and `Alex Other` / `Jordan Other`
449
449
  * are different humans who would merge happily on a name heuristic. */
450
450
  export function profileSimilarity(a: VoiceProfile, b: VoiceProfile): number {
451
451
  return rowCosine(profileCentroid(a.embeddings), profileCentroid(b.embeddings))
@@ -1,6 +1,6 @@
1
1
  // GPU admission control for post-meeting batch HQ transcription.
2
2
  //
3
- // THE PROBLEM: Miles goes meeting -> meeting. Meeting A's post-save HQ polish
3
+ // THE PROBLEM: the user goes meeting -> meeting. Meeting A's post-save HQ polish
4
4
  // (whisper-cli large-v3) often overlaps Meeting B's live ASR. Both want Metal.
5
5
  // 6.14.1 fixed the clash bluntly by pinning batch to CPU forever, which taxes
6
6
  // every idle polish to protect the overlap case.
@@ -1302,7 +1302,7 @@ export const TURN_UNKNOWN_COPY = 'COS has no record of that turn. Nothing was se
1302
1302
  export const CLIENT_TURN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/
1303
1303
 
1304
1304
  export function threadAttachEnabled(): boolean {
1305
- // Default ON since 6.37.0 (Miles 2026-08-25): ship the capability active and
1305
+ // Default ON since 6.37.0 (the user 2026-08-25): ship the capability active and
1306
1306
  // let users opt out. Absent key = on; only a literal '0' disables.
1307
1307
  return process.env.COS_THREAD_ATTACH_ENABLED !== '0'
1308
1308
  }
@@ -2252,7 +2252,7 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2252
2252
  })
2253
2253
  // A fence shuts a thread until a human acts, and until now it wrote NO log
2254
2254
  // line at either site — so a fenced thread was discoverable only by trying
2255
- // to use it (Miles, 2026-08-18). Never log `key`: it embeds the private
2255
+ // to use it (the user, 2026-08-18). Never log `key`: it embeds the private
2256
2256
  // native thread id, which this router does not emit anywhere.
2257
2257
  console.warn(`[agent-session-bindings] fence set site=ambiguous provider=${binding.provider} target=${opaqueRevision(key)} turnId=${turnId} bindingId=${bindingId} headBefore=${head.digest} adapterReason=${recordedReason} detail=${adapterEvidence.adapterDetail ?? 'none'} exitCode=${adapterEvidence.exitCode ?? 'null'} childReaped=${adapterEvidence.childReaped ?? 'unknown'} stderrClass=${adapterEvidence.stderrClass ?? 'none'} durationMs=${adapterEvidence.durationMs ?? 'unknown'} spawnCount=${recordedPids.length}`)
2258
2258
  return reportAmbiguous()
@@ -245,7 +245,7 @@ agentSessionStreamRouter.get('/agent-sessions/:provider/:sessionId/stream', asyn
245
245
  // THE SEED. The last few steps of what already happened, before anything live.
246
246
  //
247
247
  // Without it, opening a session that is already working shows an empty page that
248
- // fills one line at a time, which is what Miles reported from hardware. The screen
248
+ // fills one line at a time, which is what the user reported from hardware. The screen
249
249
  // should look like a monitor you just walked up to, not one that was switched on.
250
250
  //
251
251
  // BOUNDED TO WHAT THE LENS CAN SHOW. `SEED_EVENTS` is the live window, so the seed
@@ -198,7 +198,7 @@ export function withRunning<T extends { session_id: string }>(entry: T, scan: Oc
198
198
  * the hint from whichever list row the user tapped. That worked exactly once: the
199
199
  * detail page was a single fetch, so the borrowed flags were frozen at the moment
200
200
  * it opened and could never clear, which is half of why a finished session stayed
201
- * "active" on the glasses until Miles navigated away. A page that polls needs a
201
+ * "active" on the glasses until the user navigated away. A page that polls needs a
202
202
  * payload that carries its own liveness.
203
203
  *
204
204
  * NEARLY FREE HERE. The handler has already resolved the transcript path and
@@ -382,7 +382,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
382
382
  first_prompt: parsed.first_prompt,
383
383
  discussion_summary: parsed.discussion_summary || '',
384
384
  // Detail only. The list row (line 73) deliberately stays on the 180-char
385
- // summary — Miles: "it should be in the body not the title, the row should
385
+ // summary — the user: "it should be in the body not the title, the row should
386
386
  // be no more than the 180 characters."
387
387
  discussion_digest: parsed.discussion_digest || '',
388
388
  // The newest assistant reply, whole. ADDITIVE: the digest above still carries
@@ -43,7 +43,7 @@ export function claudeSessionsEnabled(): boolean {
43
43
  * Show real session names instead of the recomputed folder name?
44
44
  *
45
45
  * Default OFF, so the published package is safe for anyone: a `user` or `auto` name
46
- * describes the WORK ("Kevin/Miles grievance analysis"), and this socket binds
46
+ * describes the WORK ("Alex/Jordan grievance analysis"), and this socket binds
47
47
  * 0.0.0.0 behind a private-network allowlist, so anyone on the LAN holding the token
48
48
  * would read it off the wire.
49
49
  *
@@ -247,7 +247,7 @@ healthRouter.get('/health', async (_req, res) => {
247
247
  // COS Control's "Show Claude sessions" checkbox sourced its state from
248
248
  // GET /api/claude-sessions -- the call that also lists every session -- and the
249
249
  // panel never made that call. The box therefore rendered OFF whatever the
250
- // setting was, and the setting was ON: Miles enabled it four times against a
250
+ // setting was, and the setting was ON: the user enabled it four times against a
251
251
  // control that could only ever show him false.
252
252
  //
253
253
  // This is a pure env read, so it costs nothing on a health poll, and health is
@@ -30,7 +30,7 @@ import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
30
30
  * The label a de-attributed voice takes, numbered within its meeting.
31
31
  *
32
32
  * NOT a shared `Ext`. De-attributing to one label folded every corrected voice
33
- * into a single row — on the 2026-08-06 Ditto meeting Miles named five wrong
33
+ * into a single row — on the 2026-08-06 Ditto meeting the user named five wrong
34
34
  * attributions, and collapsing them would have destroyed his ability to tell
35
35
  * those five voices apart, which is precisely what he needs playback for next.
36
36
  *
@@ -807,7 +807,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
807
807
  // ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
808
808
  //
809
809
  // Corrects who a voice was in ONE meeting. Deliberately not a global merge:
810
- // Miles, on the design — "changing it doesn't mean that all previous chunks
810
+ // the user, on the design — "changing it doesn't mean that all previous chunks
811
811
  // should also be moved. It should be meeting by meeting, with the goal of
812
812
  // hardening or refining the voice profiles." The identifier mishearing a voice
813
813
  // in one room is not evidence that every past attribution was wrong.
@@ -927,9 +927,9 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
927
927
  durationMs: review.durationMs || 0,
928
928
  // Names this human explicitly de-attributed. The ledger has recorded
929
929
  // `proseStale` on every applied de-attribution since the feature shipped and
930
- // NOTHING has ever read it: on 2026-08-07 Miles removed "Clem Ukaoma" from a
931
- // call that was only him and Queen, all 8 label sites were rewritten, and the
932
- // summary still opened "Miles, Queen, and Clem talk through..." with no
930
+ // NOTHING has ever read it: on 2026-08-07 the user removed "Morgan Example" from a
931
+ // call that had only two participants, all 8 label sites were rewritten, and the
932
+ // summary still opened "the user, Sam, and Morgan talk through..." with no
933
933
  // indication anywhere in the payload.
934
934
  removed: appliedCorrections(sessionId)
935
935
  .filter(r => isUnattributed(r.to))
@@ -1292,7 +1292,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1292
1292
  //
1293
1293
  // The inverse of naming an unknown voice: this voice was NOT that person.
1294
1294
  //
1295
- // Miles, on the 2026-08-06 Ditto meeting: none of the eleven attributed
1295
+ // the user, on the 2026-08-06 Ditto meeting: none of the eleven attributed
1296
1296
  // voices were actually in the room, and there was no way to say so. Naming an
1297
1297
  // unknown was possible; un-naming a wrong guess was not.
1298
1298
  //
@@ -1633,7 +1633,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1633
1633
 
1634
1634
  // ── Review playback (6.21.18) ─────────────────────────────────────────
1635
1635
  //
1636
- // Miles: "I can quickly play that, and I'm going to hear the voice and know
1636
+ // the user: "I can quickly play that, and I'm going to hear the voice and know
1637
1637
  // immediately who the speaker is. As a final confirmation."
1638
1638
  //
1639
1639
  // A phrase in the panel is a weaker signal than three seconds of the actual
@@ -1781,7 +1781,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1781
1781
  })
1782
1782
 
1783
1783
  // ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
1784
- // Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
1784
+ // Surface-only by decision (the user, 2026-08-02): the server NEVER drives
1785
1785
  // recovery on its own. It lists what the quarantine holds, and one
1786
1786
  // authenticated POST drives one capture to a durable scribe.
1787
1787
  const recoveringOrphans = new Set<string>()
@@ -54,6 +54,41 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
54
54
  } from '../lib/cos-context-browser.js'
55
55
 
56
56
  export const memoryRouter = Router()
57
+ memoryRouter.use((req, res, next) => {
58
+ if (['owner', 'audience', 'authority_host', 'authority_epoch'].some(key => key in req.query || (req.body && key in req.body))) {
59
+ res.status(400).json({ error: 'caller_authority_forbidden' }); return
60
+ }
61
+ next()
62
+ })
63
+ // Additive workspace protocol. The existing /memory array contract remains intact.
64
+ // Authentication is the instance pairing token; caller fields cannot select an owner.
65
+ const WORKSPACE_ACTIONS = new Set(['status', 'graph_expand', 'graph_paths', 'graph_resolve', 'list_explorations', 'get_exploration',
66
+ 'save_exploration', 'save_assertion', 'policy_get', 'policy_set', 'memory_page', 'learning_page', 'review_page', 'get_memory', 'review_memory', 'trace_summary', 'source_status', 'refresh_source', 'current_sources', 'identity_split_preview', 'identity_status', 'identity_keep_apart', 'rule_page', 'propose_rule', 'review_rule', 'rollback_rule', 'activate_rule', 'rule_evaluation'])
67
+ memoryRouter.post('/context/memory/workspace', async (req, res) => {
68
+ noStore(res)
69
+ const body = req.body
70
+ if (!body || typeof body !== 'object' || Array.isArray(body) || !WORKSPACE_ACTIONS.has(body.action)
71
+ || ['owner','audience','actor','path','authority_host','authority_epoch'].some(key => key in body)) {
72
+ res.status(400).json({ error: 'invalid_workspace_request' }); return
73
+ }
74
+ const input = JSON.stringify(body)
75
+ if (Buffer.byteLength(input) > 512 * 1024) { res.status(413).json({ error: 'request_too_large' }); return }
76
+ const cancellation = new AbortController()
77
+ res.once('close', () => { if (!res.writableEnded) cancellation.abort() })
78
+ try {
79
+ const result = await callPython(['memory-workspace'], 20_000, input, cancellation.signal) as Record<string, unknown>
80
+ if (!result || typeof result !== 'object' || result.error) {
81
+ res.status(409).json(result || { error: 'invalid_workspace_response' }); return
82
+ }
83
+ if (result.protocol !== 1 || Buffer.byteLength(JSON.stringify(result)) > 900 * 1024) {
84
+ res.status(502).json({ error: 'unsupported_workspace_response' }); return
85
+ }
86
+ res.json(result)
87
+ } catch (error) {
88
+ res.status(503).json({ error: 'workspace_unavailable', message: error instanceof Error ? error.message : 'Workspace unavailable' })
89
+ }
90
+ })
91
+
57
92
  let overviewCache: { expiresAt: number; value: ReturnType<typeof normalizeMemoryOverview> } | null = null
58
93
 
59
94
  memoryRouter.get('/context/status', async (_req, res) => {
@@ -211,7 +246,7 @@ memoryRouter.post('/context/learning/:id/review', async (req, res) => {
211
246
 
212
247
  // ── Memory review and guardrails (6.44.13) ──────────────────────────
213
248
  //
214
- // Accept stamps a captured memory as reviewed; prune deletes it. Both are
249
+ // Accept activates a captured memory; prune quarantines it. Both are
215
250
  // review-ledger rows the timeline shows. The guardrails are the user's own
216
251
  // rules; a run scans, judges (rules, then an optional bounded model pass),
217
252
  // and prunes only with apply.
@@ -228,10 +263,10 @@ memoryRouter.post('/context/memory/:id/review', async (req, res) => {
228
263
  const answer = await callPython(['memory-review', `--id=${memoryId}`, `--decision=${decision}`, ...(note ? [`--note=${note}`] : [])], 20_000)
229
264
  const code = bridgeErrorCode(answer)
230
265
  if (code) { sendSetupError(res, code, answer); return }
231
- const a = answer as { decision?: unknown; deleted?: unknown }
266
+ const a = answer as { decision?: unknown; deleted?: unknown; quarantined?: unknown; receipt?: unknown }
232
267
  const row = normalizeReviewDecision({ decision: a.decision })
233
268
  if (!row) { res.status(503).json({ error: 'memory_review_unavailable' }); return }
234
- res.json({ decision: row, deleted: a.deleted === true })
269
+ res.json({ decision: row, deleted: a.deleted === true, ...(a.quarantined === true ? { quarantined: true } : {}), ...(a.receipt ? { receipt: a.receipt } : {}) })
235
270
  } catch (error) {
236
271
  console.warn('[context] memory review bridge failure:', (error as Error).message)
237
272
  res.status(503).json({ error: 'memory_unavailable' })
@@ -297,12 +332,12 @@ memoryRouter.post('/context/memory-guardrails/run', async (req, res) => {
297
332
 
298
333
  // ── Curation (6.44.14): the Manage sheet's merge, with a preview, a worker and a receipt ──
299
334
  //
300
- // Miles 2026-09-08: "Build the Manage merge path with the preview" and "address any of
301
- // the obvious duplicates like the miels and queen example without clobbering entities."
335
+ // the user 2026-09-08: "Build the Manage merge path with the preview" and "address any of
336
+ // the obvious duplicates like the alxe and sam example without clobbering entities."
302
337
  // The merge is a DETACHED worker: the two hand merges of 2026-09-08 took ~2 minutes of
303
338
  // vector-store rewriting, far past any request budget. The kickoff answers 202 with a
304
339
  // ticket; GET /context/graph/merge is the receipt to poll. Two people the graph knows
305
- // to be different (Miles Ukaoma / Miles Mallard) come back 409 merge_blocked.
340
+ // to be different (Alex Example / Alex Other) come back 409 merge_blocked.
306
341
 
307
342
  function mergeNames(body: unknown): { source: string; target: string } | null {
308
343
  const b = (body ?? {}) as { source?: unknown; target?: unknown }
@@ -572,6 +607,7 @@ memoryRouter.get('/context/graph/ingest/progress', async (_req, res) => {
572
607
  function sendSetupError(res: import('express').Response, code: string, data: unknown): void {
573
608
  const detail = asDetail(data)
574
609
  if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
610
+ if (code === 'governed_merge_unavailable') { res.status(409).json({ error: code, message: detail.message }); return }
575
611
  if (code === 'embedding_locked' || code === 'embedding_mismatch') { res.status(409).json({ error: code, message: detail.message }); return }
576
612
  if (code === 'merge_blocked') {
577
613
  // 6.44.14: two entities the graph knows to be different people. The preview rides along so the page can say why.
@@ -676,8 +712,10 @@ memoryRouter.post('/context/graph/ask', async (req, res) => {
676
712
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
677
713
  const q = typeof (req.body as { q?: unknown } | undefined)?.q === 'string' ? ((req.body as { q: string }).q).trim() : ''
678
714
  if (q.length < 3 || q.length > KNOWLEDGE_ASK_MAX_CHARS) { res.status(400).json({ error: 'invalid_query', message: `q must be 3 to ${KNOWLEDGE_ASK_MAX_CHARS} characters` }); return }
715
+ const cancellation = new AbortController()
716
+ res.once('close', () => { if (!res.writableEnded) cancellation.abort() })
679
717
  try {
680
- const data = await callPython(['graph-ask', `--q=${q}`], 150_000)
718
+ const data = await callPython(['graph-ask', `--q=${q}`], 150_000, undefined, cancellation.signal)
681
719
  const code = bridgeErrorCode(data)
682
720
  if (code) { sendSetupError(res, code, data); return }
683
721
  const answer = normalizeGraphAnswer(data)
@@ -107,7 +107,7 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
107
107
  async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { signal?: AbortSignal }): Promise<string> {
108
108
  let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
109
109
  // applyCorrections above is an EXACT string map, so it only fixes misspellings someone
110
- // already hand-authored. A novel miss ("Miyala" for Niala, "Yukoma" for Ukaoma) sails
110
+ // already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
111
111
  // through it. The Levenshtein pass is what catches those, and until now it had exactly
112
112
  // ONE call site — transcribe-audio.ts:252, the server-transcription route — so phone
113
113
  // Moonshine dictation, which arrives here as text, never got it. Same construction and
@@ -29,7 +29,7 @@
29
29
  // process also streams live audio, so that blocked the event loop. Now async
30
30
  // and cached against file identity.
31
31
  // 4. The filename filter matched iCloud sync-conflict duplicates. On this machine
32
- // `.session_index_cache_Ukaoma-Mac-Studio 3.json` shares 543 of its 602 rows
32
+ // `.session_index_cache_Example-Mac-Studio 3.json` shares 543 of its 602 rows
33
33
  // with the canonical file, so the merged list served 543 duplicates. Filtering
34
34
  // ` N` filenames out would be wrong in the other direction: ` 2.json` holds 245
35
35
  // rows that appear nowhere else. Dedupe therefore happens by session_id, newest
@@ -102,13 +102,17 @@ export async function drainThread(
102
102
 
103
103
  let delivered = 0, held = 0, retired = 0
104
104
  let dirty = false
105
+ let refusedGate: ReturnType<ThreadTurnQueueDeps['occupancy']> | null = null
105
106
 
106
107
  for (const turn of queue) {
107
108
  if (turn.status !== 'waiting') continue
108
109
 
109
- // Observed FRESH for each turn: delivering one makes the thread busy again, so a
110
- // verdict from the top of the loop would be stale by the second item.
111
- const gate = deps.occupancy(provider, threadId)
110
+ // Reuse only refusal within this synchronous pass. Rechecking a busy thread
111
+ // for every queued row repeats a whole-process descriptor scan. A refusal
112
+ // can delay delivery until the next sweep, but can never authorize a write.
113
+ // Positive verdicts and all checks after a delivery remain fresh.
114
+ const gate: ReturnType<ThreadTurnQueueDeps['occupancy']> = refusedGate ?? deps.occupancy(provider, threadId)
115
+ if (!gate.attachable) refusedGate = gate
112
116
  const seen: DrainObservation = {
113
117
  attachable: gate.attachable,
114
118
  turnEnded: deps.turnEnded(provider, threadId),
@@ -135,11 +139,13 @@ export async function drainThread(
135
139
  dirty = true
136
140
 
137
141
  let outcome: { ok: boolean; reason?: string; serverRetryable?: boolean }
142
+ refusedGate = null
138
143
  try {
139
144
  outcome = await deps.deliver(turn)
140
145
  } catch (error) {
141
146
  outcome = { ok: false, reason: error instanceof Error ? error.message : 'deliver_threw' }
142
147
  }
148
+ refusedGate = null
143
149
 
144
150
  if (outcome.ok) {
145
151
  turn.status = 'delivered'
@@ -147,7 +153,7 @@ export async function drainThread(
147
153
  delivered += 1
148
154
  } else if (isRetryableDelivery(outcome)) {
149
155
  // A GATE REFUSAL IS NOT A FAILED DELIVERY, so it must not spend the ceiling --
150
- // measured cost of getting this wrong: three of Miles's turns retired in about
156
+ // measured cost of getting this wrong: three of the user's turns retired in about
151
157
  // two minutes each, never delivered, while the 6h TTL never got to matter.
152
158
  //
153
159
  // REFUNDED, not deferred. The increment and its fsync stay BEFORE the call
@@ -144,7 +144,7 @@ const THANKYOU_FILTER = process.env.COS_WHISPER_THANKYOU_FILTER !== '0'
144
144
  const MEETING_PREVIEW_MAX_BYTES = 512 * 1024
145
145
  let meetingPreviewBusy = false
146
146
 
147
- // DEFAULT ON as of 6.21.22 (Miles, 2026-08-06: "I want it to default to being
147
+ // DEFAULT ON as of 6.21.22 (the user, 2026-08-06: "I want it to default to being
148
148
  // on... it's stable and makes meetings so much better"). Opt-OUT now, matching
149
149
  // STRIP_BRAND_URLS / THANKYOU_FILTER above: set COS_WHISPER_MEETING_PREVIEW=0
150
150
  // to disable. The committed Large-v3 transcript remains authoritative and