@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.
- package/CHANGELOG.md +77 -51
- package/README.md +9 -0
- package/bin/memory-setup.cjs +55 -0
- package/memory-runtime/cos-memory-runtime-0.1.0.tar.gz +0 -0
- package/memory-runtime/runtime.json +7 -0
- package/package.json +7 -4
- package/server/index.ts +4 -4
- package/server/lib/adaptive-playback-audio.ts +1 -1
- package/server/lib/agent-session-store.ts +139 -28
- package/server/lib/archive-search.ts +1 -1
- package/server/lib/attached-provider-adapter.ts +1 -1
- package/server/lib/attached-workspace.ts +3 -3
- package/server/lib/chunk-embedding-store.ts +1 -1
- package/server/lib/context-files.ts +1 -1
- package/server/lib/cos-context-browser.ts +27 -8
- package/server/lib/cos-operations-meetings.ts +1 -1
- package/server/lib/domains.ts +2 -2
- package/server/lib/maintenance-lifecycle.ts +1 -1
- package/server/lib/meeting-audio-archive.ts +3 -3
- package/server/lib/meeting-corrections.ts +3 -3
- package/server/lib/meeting-relabel-enrolment.ts +1 -1
- package/server/lib/meeting-relabel.ts +2 -2
- package/server/lib/meeting-scribe-content.ts +7 -7
- package/server/lib/meeting-speaker-review.ts +3 -3
- package/server/lib/meeting-summary.ts +1 -1
- package/server/lib/message-era-reset.ts +1 -1
- package/server/lib/message-era.ts +1 -1
- package/server/lib/morning-brief-config.ts +1 -1
- package/server/lib/morning-brief-coverage.ts +1 -1
- package/server/lib/occupancy-probes.ts +7 -4
- package/server/lib/ollama-tools.ts +1 -1
- package/server/lib/profile.ts +29 -5
- package/server/lib/python-bridge.ts +6 -4
- package/server/lib/query-job-types.ts +1 -1
- package/server/lib/session-stream-events.ts +3 -3
- package/server/lib/session-transcript-watcher.ts +1 -1
- package/server/lib/speaker-calibration-log.ts +1 -1
- package/server/lib/speaker-name.ts +1 -1
- package/server/lib/thread-occupancy.ts +1 -1
- package/server/lib/thread-turn-queue-deliver.ts +1 -1
- package/server/lib/thread-turn-queue.ts +3 -3
- package/server/lib/video-upload-v2.ts +1 -1
- package/server/lib/voice-profile-store.ts +2 -2
- package/server/lib/whisper-metal-gate.ts +1 -1
- package/server/routes/agent-session-bindings.ts +2 -2
- package/server/routes/agent-session-stream.ts +1 -1
- package/server/routes/agent-sessions.ts +2 -2
- package/server/routes/claude-sessions.ts +1 -1
- package/server/routes/health.ts +3 -1
- package/server/routes/meeting.ts +8 -8
- package/server/routes/memory.ts +66 -7
- package/server/routes/prompt-drafts.ts +1 -1
- package/server/routes/session-index.ts +1 -1
- package/server/routes/thread-turn-queue.ts +10 -4
- package/server/routes/transcribe-stream.ts +15 -4
- 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 (
|
|
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.
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
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
|
*/
|
package/server/lib/profile.ts
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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 "
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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 --
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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 `
|
|
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:
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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 —
|
|
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 ("
|
|
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
|
*
|
package/server/routes/health.ts
CHANGED
|
@@ -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:
|
|
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
|
})),
|
package/server/routes/meeting.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
|
931
|
-
// call that
|
|
932
|
-
// summary still opened "
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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 (
|
|
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>()
|
package/server/routes/memory.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
301
|
-
// the obvious duplicates like the
|
|
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 (
|
|
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)
|