@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.
- package/CHANGELOG.md +83 -49
- 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 +6 -3
- 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 +17 -6
- package/server/lib/cos-operations-meetings.ts +15 -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/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 +1 -1
- package/server/routes/meeting.ts +8 -8
- package/server/routes/memory.ts +45 -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 +1 -1
- 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
|
*/
|
|
@@ -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
|
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
|
@@ -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
|
|
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
|
-
//
|
|
301
|
-
// the obvious duplicates like the
|
|
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 (
|
|
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 ("
|
|
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
|
-
// `.
|
|
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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
|
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 (
|
|
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
|