@gotcos/glasses-server 6.44.16 → 6.45.1

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 +77 -51
  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 +7 -4
  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 +27 -8
  16. package/server/lib/cos-operations-meetings.ts +1 -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/profile.ts +29 -5
  33. package/server/lib/python-bridge.ts +6 -4
  34. package/server/lib/query-job-types.ts +1 -1
  35. package/server/lib/session-stream-events.ts +3 -3
  36. package/server/lib/session-transcript-watcher.ts +1 -1
  37. package/server/lib/speaker-calibration-log.ts +1 -1
  38. package/server/lib/speaker-name.ts +1 -1
  39. package/server/lib/thread-occupancy.ts +1 -1
  40. package/server/lib/thread-turn-queue-deliver.ts +1 -1
  41. package/server/lib/thread-turn-queue.ts +3 -3
  42. package/server/lib/video-upload-v2.ts +1 -1
  43. package/server/lib/voice-profile-store.ts +2 -2
  44. package/server/lib/whisper-metal-gate.ts +1 -1
  45. package/server/routes/agent-session-bindings.ts +2 -2
  46. package/server/routes/agent-session-stream.ts +1 -1
  47. package/server/routes/agent-sessions.ts +2 -2
  48. package/server/routes/claude-sessions.ts +1 -1
  49. package/server/routes/health.ts +3 -1
  50. package/server/routes/meeting.ts +8 -8
  51. package/server/routes/memory.ts +66 -7
  52. package/server/routes/prompt-drafts.ts +1 -1
  53. package/server/routes/session-index.ts +1 -1
  54. package/server/routes/thread-turn-queue.ts +10 -4
  55. package/server/routes/transcribe-stream.ts +15 -4
  56. 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
  */
@@ -1,14 +1,14 @@
1
1
  // Profile loader — reads user identity from .cos-profile.json (gitignored)
2
2
  // Falls back to generic defaults for users who haven't configured a profile
3
3
 
4
- import { existsSync, readFileSync } from 'node:fs'
4
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
5
5
  import { homedir } from 'node:os'
6
- import { resolve } from 'node:path'
6
+ import { resolve, dirname } from 'node:path'
7
7
  import { atomicWriteFileSync } from './atomic-fs.js'
8
8
 
9
9
  const APP_ROOT = resolve(import.meta.dirname, '../..')
10
10
 
11
- const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user'])
11
+ const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user', 'me', 'owner', 'wearer'])
12
12
  const PLACEHOLDER_VOCABULARY = new Set(['nameone', 'nametwo', 'yourcompany', 'productname'])
13
13
  const PLACEHOLDER_CORRECTIONS = new Set(['soundalike\u0000yourname'])
14
14
 
@@ -53,8 +53,9 @@ export function loadProfileObject(): Record<string, unknown> {
53
53
  function loadProfile(): Record<string, unknown> {
54
54
  if (profileCache) return profileCache
55
55
  try {
56
- profileCache = JSON.parse(readFileSync(profilePath(), 'utf-8'))
57
- return profileCache!
56
+ const parsed: unknown = JSON.parse(readFileSync(profilePath(), 'utf-8'))
57
+ profileCache = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}
58
+ return profileCache
58
59
  } catch {
59
60
  profileCache = {}
60
61
  return profileCache
@@ -98,6 +99,29 @@ export function getOwnerName(): string {
98
99
  return !value || PLACEHOLDER_OWNER_NAMES.has(value.toLowerCase()) ? 'User' : value
99
100
  }
100
101
 
102
+ /** Display identity only. Never reassign journal authority or the ingestion-owner Mac. */
103
+ export function setProfileOwnerName(name: unknown, expected: unknown): string {
104
+ if (typeof name !== 'string' || !name.trim() || name.trim().length > 120 || /[\x00-\x1f\x7f]/.test(name) || PLACEHOLDER_OWNER_NAMES.has(name.trim().toLowerCase())) throw new Error('invalid_owner_name')
105
+ if (expected !== null && typeof expected !== 'string') throw new Error('invalid_expected_owner')
106
+ let current: Record<string, unknown> = {}
107
+ const source = profilePath()
108
+ try {
109
+ const parsed: unknown = JSON.parse(readFileSync(source, 'utf8'))
110
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid_profile')
111
+ current = parsed as Record<string, unknown>
112
+ } catch (error) {
113
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw new Error('profile_unreadable')
114
+ }
115
+ const previous = typeof current.owner_name === 'string' ? current.owner_name.trim() : ''
116
+ const actual = !previous || PLACEHOLDER_OWNER_NAMES.has(previous.toLowerCase()) ? null : previous
117
+ if (actual !== expected) throw new Error('owner_changed')
118
+ const target = process.env.COS_PROFILE_PATH?.trim() ? source : homeProfilePath()
119
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
120
+ atomicWriteFileSync(target, JSON.stringify({ ...current, owner_name: name.trim() }, null, 2), { mode: 0o600 })
121
+ clearProfileCache()
122
+ return name.trim()
123
+ }
124
+
101
125
  /** Short speaker label for the glasses wearer, used by diarization to fast-path
102
126
  * the owner's voiceprint. Defaults to 'Me'. Configure via owner_speaker_label. */
103
127
  export function getOwnerSpeakerLabel(): string {
@@ -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
@@ -329,6 +329,8 @@ healthRouter.get('/health', async (_req, res) => {
329
329
  idleMinutes: item.idleMinutes,
330
330
  capturedMinutes: item.capturedMinutes,
331
331
  chunks: item.chunks,
332
+ transcriptState: item.transcriptState,
333
+ canSave: item.canSave,
332
334
  promotesAt: item.promotesAt,
333
335
  hasDraft: item.draftPath != null,
334
336
  })),
@@ -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>()
@@ -1,6 +1,9 @@
1
1
  import { Router } from 'express'
2
2
  import { callPython, contextSourceAvailable, pythonBridgeState } from '../lib/python-bridge.js'
3
3
  import { searchMemories } from '../lib/context-library-search.js'
4
+ import { getOwnerName, setProfileOwnerName } from '../lib/profile.js'
5
+ import { resetDecoderCaches } from '../lib/whisper-local.js'
6
+ import { resetVocabEchoCache } from '../lib/hallucination-filter.js'
4
7
 
5
8
  /**
6
9
  * Is there anything to serve — a Python bridge OR plain files on disk?
@@ -54,6 +57,59 @@ import { normalizeReviewDecision, LEARNING_REVIEW_LIMIT,
54
57
  } from '../lib/cos-context-browser.js'
55
58
 
56
59
  export const memoryRouter = Router()
60
+ // Profile setup is available before a memory bridge is configured. API authentication still applies.
61
+ memoryRouter.get('/context/profile/owner', (_req, res) => {
62
+ const name = getOwnerName()
63
+ res.json({ owner_name: name === 'User' ? null : name })
64
+ })
65
+ memoryRouter.post('/context/profile/owner', (req, res) => {
66
+ try {
67
+ const body = req.body as Record<string, unknown>
68
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !['owner_name','expected_owner'].includes(key))) { res.status(400).json({ error: 'invalid_owner_profile' }); return }
69
+ const name = setProfileOwnerName(body.owner_name, body.expected_owner)
70
+ resetDecoderCaches(); resetVocabEchoCache()
71
+ res.json({ owner_name: name })
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : ''
74
+ const reason = ['owner_changed','invalid_owner_name','invalid_expected_owner','profile_unreadable'].includes(message) ? message : 'profile_save_failed'
75
+ res.status(reason === 'owner_changed' ? 409 : reason.startsWith('invalid_') ? 400 : 503).json({ error: reason })
76
+ }
77
+ })
78
+ memoryRouter.use((req, res, next) => {
79
+ if (['owner', 'audience', 'authority_host', 'authority_epoch'].some(key => key in req.query || (req.body && key in req.body))) {
80
+ res.status(400).json({ error: 'caller_authority_forbidden' }); return
81
+ }
82
+ next()
83
+ })
84
+ // Additive workspace protocol. The existing /memory array contract remains intact.
85
+ // Authentication is the instance pairing token; caller fields cannot select an owner.
86
+ const WORKSPACE_ACTIONS = new Set(['status', 'graph_overview', 'graph_expand', 'graph_paths', 'graph_resolve', 'list_explorations', 'get_exploration',
87
+ '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'])
88
+ memoryRouter.post('/context/memory/workspace', async (req, res) => {
89
+ noStore(res)
90
+ const body = req.body
91
+ if (!body || typeof body !== 'object' || Array.isArray(body) || !WORKSPACE_ACTIONS.has(body.action)
92
+ || ['owner','audience','actor','path','authority_host','authority_epoch'].some(key => key in body)) {
93
+ res.status(400).json({ error: 'invalid_workspace_request' }); return
94
+ }
95
+ const input = JSON.stringify(body)
96
+ if (Buffer.byteLength(input) > 512 * 1024) { res.status(413).json({ error: 'request_too_large' }); return }
97
+ const cancellation = new AbortController()
98
+ res.once('close', () => { if (!res.writableEnded) cancellation.abort() })
99
+ try {
100
+ const result = await callPython(['memory-workspace'], 20_000, input, cancellation.signal) as Record<string, unknown>
101
+ if (!result || typeof result !== 'object' || result.error) {
102
+ res.status(409).json(result || { error: 'invalid_workspace_response' }); return
103
+ }
104
+ if (result.protocol !== 1 || Buffer.byteLength(JSON.stringify(result)) > 900 * 1024) {
105
+ res.status(502).json({ error: 'unsupported_workspace_response' }); return
106
+ }
107
+ res.json(result)
108
+ } catch (error) {
109
+ res.status(503).json({ error: 'workspace_unavailable', message: error instanceof Error ? error.message : 'Workspace unavailable' })
110
+ }
111
+ })
112
+
57
113
  let overviewCache: { expiresAt: number; value: ReturnType<typeof normalizeMemoryOverview> } | null = null
58
114
 
59
115
  memoryRouter.get('/context/status', async (_req, res) => {
@@ -211,7 +267,7 @@ memoryRouter.post('/context/learning/:id/review', async (req, res) => {
211
267
 
212
268
  // ── Memory review and guardrails (6.44.13) ──────────────────────────
213
269
  //
214
- // Accept stamps a captured memory as reviewed; prune deletes it. Both are
270
+ // Accept activates a captured memory; prune quarantines it. Both are
215
271
  // review-ledger rows the timeline shows. The guardrails are the user's own
216
272
  // rules; a run scans, judges (rules, then an optional bounded model pass),
217
273
  // and prunes only with apply.
@@ -228,10 +284,10 @@ memoryRouter.post('/context/memory/:id/review', async (req, res) => {
228
284
  const answer = await callPython(['memory-review', `--id=${memoryId}`, `--decision=${decision}`, ...(note ? [`--note=${note}`] : [])], 20_000)
229
285
  const code = bridgeErrorCode(answer)
230
286
  if (code) { sendSetupError(res, code, answer); return }
231
- const a = answer as { decision?: unknown; deleted?: unknown }
287
+ const a = answer as { decision?: unknown; deleted?: unknown; quarantined?: unknown; receipt?: unknown }
232
288
  const row = normalizeReviewDecision({ decision: a.decision })
233
289
  if (!row) { res.status(503).json({ error: 'memory_review_unavailable' }); return }
234
- res.json({ decision: row, deleted: a.deleted === true })
290
+ res.json({ decision: row, deleted: a.deleted === true, ...(a.quarantined === true ? { quarantined: true } : {}), ...(a.receipt ? { receipt: a.receipt } : {}) })
235
291
  } catch (error) {
236
292
  console.warn('[context] memory review bridge failure:', (error as Error).message)
237
293
  res.status(503).json({ error: 'memory_unavailable' })
@@ -297,12 +353,12 @@ memoryRouter.post('/context/memory-guardrails/run', async (req, res) => {
297
353
 
298
354
  // ── Curation (6.44.14): the Manage sheet's merge, with a preview, a worker and a receipt ──
299
355
  //
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."
356
+ // the user 2026-09-08: "Build the Manage merge path with the preview" and "address any of
357
+ // the obvious duplicates like the alxe and sam example without clobbering entities."
302
358
  // The merge is a DETACHED worker: the two hand merges of 2026-09-08 took ~2 minutes of
303
359
  // vector-store rewriting, far past any request budget. The kickoff answers 202 with a
304
360
  // 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.
361
+ // to be different (Alex Example / Alex Other) come back 409 merge_blocked.
306
362
 
307
363
  function mergeNames(body: unknown): { source: string; target: string } | null {
308
364
  const b = (body ?? {}) as { source?: unknown; target?: unknown }
@@ -572,6 +628,7 @@ memoryRouter.get('/context/graph/ingest/progress', async (_req, res) => {
572
628
  function sendSetupError(res: import('express').Response, code: string, data: unknown): void {
573
629
  const detail = asDetail(data)
574
630
  if (code === 'not_owner') { res.status(409).json({ error: code, message: detail.message, owner_host: detail.owner_host ?? null }); return }
631
+ if (code === 'governed_merge_unavailable') { res.status(409).json({ error: code, message: detail.message }); return }
575
632
  if (code === 'embedding_locked' || code === 'embedding_mismatch') { res.status(409).json({ error: code, message: detail.message }); return }
576
633
  if (code === 'merge_blocked') {
577
634
  // 6.44.14: two entities the graph knows to be different people. The preview rides along so the page can say why.
@@ -676,8 +733,10 @@ memoryRouter.post('/context/graph/ask', async (req, res) => {
676
733
  if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
677
734
  const q = typeof (req.body as { q?: unknown } | undefined)?.q === 'string' ? ((req.body as { q: string }).q).trim() : ''
678
735
  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 }
736
+ const cancellation = new AbortController()
737
+ res.once('close', () => { if (!res.writableEnded) cancellation.abort() })
679
738
  try {
680
- const data = await callPython(['graph-ask', `--q=${q}`], 150_000)
739
+ const data = await callPython(['graph-ask', `--q=${q}`], 150_000, undefined, cancellation.signal)
681
740
  const code = bridgeErrorCode(data)
682
741
  if (code) { sendSetupError(res, code, data); return }
683
742
  const answer = normalizeGraphAnswer(data)