@gotcos/glasses-server 6.44.1 → 6.44.2

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 CHANGED
@@ -1,3 +1,12 @@
1
+ ## 6.44.2
2
+
3
+ Task domains come from the user, not from the build.
4
+
5
+ - `FULL_DOMAINS` was `['quilt','personal','hermit_crabs','sprocket_rocket']` — one user's business units, compiled into everyone's copy. The task store and dispatcher now resolve domains through `taskDomainNames`, a union of the profile's `domains` and every directory under `operations/` holding a `tasks.md`. On an install with no configured domains this returns exactly the folders already there, so nothing changes for an existing setup; a fresh COS gets `personal` and `business`.
6
+ - Task discovery keys on `tasks.md`, deliberately NOT on the `meetings/YYYY-MM` tree that `discoveredDomains` requires. Measured here: `uncategorized/` has a months tree and no `tasks.md`, so reusing meetings discovery would have offered a task domain whose file does not exist, and a domain with tasks but no meetings yet would have been invisible.
7
+ - Capture now rejects an unknown domain with the name it was given instead of "full domain required".
8
+ - New `GET /api/domains` returns `{ domains: [{ name, abbr, label }], gate }` so no client hardcodes a picker. `abbr` is derived server-side, ending the two disagreeing abbreviation maps; `label` is derived from the key and leaves a name the user wrote as prose ("DNP study") untouched.
9
+
1
10
  ## 6.44.1
2
11
 
3
12
  Board and lens titles are readable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.1",
3
+ "version": "6.44.2",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,13 @@
1
+ /** Human label for a domain key.
2
+ *
3
+ * `hermit_crabs` should read "Hermit Crabs" in a picker, but the label must be
4
+ * DERIVED, never a lookup table: a table is how one user's four business units
5
+ * ended up compiled into everyone's build. A user-supplied domain that is
6
+ * already prose ("DNP study") is returned as-is apart from its separators. */
7
+ export function domainLabel(name: string): string {
8
+ const words = name.split(/[_-]+/).filter(Boolean)
9
+ if (words.length === 0) return name
10
+ // Only lowercase words are title-cased. A name the user already capitalised
11
+ // ("DNP study", "iOS") keeps their capitalisation.
12
+ return words.map(w => (w === w.toLowerCase() ? w.charAt(0).toUpperCase() + w.slice(1) : w)).join(' ')
13
+ }
@@ -134,6 +134,45 @@ export function discoveredDomains(operationsDir: string | null): string[] {
134
134
  }).sort()
135
135
  }
136
136
 
137
+ /**
138
+ * Immediate subdirectories of `operationsDir` holding a `tasks.md`.
139
+ *
140
+ * A SEPARATE signal from `discoveredDomains`, deliberately. That one requires a
141
+ * `meetings/YYYY-MM` tree, which is the right shape for the meeting lister and
142
+ * the wrong shape here: measured on this install, `uncategorized/` has a months
143
+ * tree and no `tasks.md`, so reusing meetings discovery would offer a task
144
+ * domain whose file does not exist, while a domain with tasks and no meetings
145
+ * yet would be invisible. The task store reads `tasks.md`, so that is the file
146
+ * that decides.
147
+ */
148
+ export function discoveredTaskDomains(operationsDir: string | null): string[] {
149
+ if (!operationsDir || !existsSync(operationsDir)) return []
150
+ let names: string[]
151
+ try {
152
+ names = readdirSync(operationsDir, { withFileTypes: true })
153
+ .filter(e => e.isDirectory()).map(e => e.name)
154
+ } catch { return [] }
155
+ return names.filter(isSafeDomainName).filter(name => {
156
+ try { return statSync(join(operationsDir, name, 'tasks.md')).isFile() } catch { return false }
157
+ }).sort()
158
+ }
159
+
160
+ /**
161
+ * Task domains: configured first, then any directory with a `tasks.md`.
162
+ *
163
+ * Same union rule as `resolveDomains` and for the same reason — Miles has no
164
+ * `domains` in his profile, discovery finds his four, and the union is exactly
165
+ * his four, so switching the task store onto this changes nothing about his
166
+ * install while removing one user's business units from everyone else's build.
167
+ */
168
+ export function taskDomainNames(operationsDir: string | null): string[] {
169
+ const configured = configuredDomains().map(d => d.name)
170
+ const discovered = discoveredTaskDomains(operationsDir)
171
+ if (configured.length === 0 && discovered.length === 0) return [...DEFAULT_DOMAINS]
172
+ const known = new Set(configured.map(n => n.toLowerCase()))
173
+ return [...configured, ...discovered.filter(n => !known.has(n.toLowerCase()))]
174
+ }
175
+
137
176
  /**
138
177
  * Every domain this COS has, as a UNION of configuration and what is on disk.
139
178
  *
@@ -29,7 +29,7 @@ import {
29
29
  type QueryJobSnapshot,
30
30
  } from './query-job-types.js'
31
31
  import {
32
- FULL_DOMAINS,
32
+ taskDomains,
33
33
  TASK_DISPATCH_LIMITS,
34
34
  TASK_DISPATCH_WALL_MS,
35
35
  TASK_JOB_LOST_MS,
@@ -597,7 +597,7 @@ export async function reconcileDispatch(injected?: TaskDispatcherDeps): Promise<
597
597
  }
598
598
  const timer = setTimeout(() => lease?.release(), TASK_LEASE_CEILING_MS)
599
599
  try {
600
- const groups = await Promise.all(FULL_DOMAINS.map(domain => (deps.loadRows ? deps.loadRows(clock.day) : loadDomainRows(domain, clock.day))))
600
+ const groups = await Promise.all(taskDomains().map(domain => (deps.loadRows ? deps.loadRows(clock.day) : loadDomainRows(domain, clock.day))))
601
601
  const rows = groups.flat()
602
602
  const ledger = loadTaskLedger(pathsOf(deps))
603
603
  const live = ledger.filter(run => run.status === 'dispatching' || run.status === 'running')
@@ -10,6 +10,8 @@ import {
10
10
  } from './morning-brief-config.js'
11
11
  import { localClock, shiftDay, taskInstant } from './morning-brief-schedule.js'
12
12
  import { callPython, pythonBridgeAvailable } from './python-bridge.js'
13
+ import { resolveCosOperationsDir } from './cos-operations-meetings.js'
14
+ import { taskDomainNames } from './domains.js'
13
15
  import { isClientJobId } from './query-job-types.js'
14
16
  import { DEFAULT_MODEL, isClaudeModel } from '../../shared/model-preference.js'
15
17
 
@@ -30,8 +32,16 @@ export const TASK_BRIDGE_TIMEOUT_MS = 12_000
30
32
  export const TASK_JOB_LOST_MS = 6 * 60 * 60_000
31
33
  export const TASK_LOCK_RETRY = Object.freeze({ attempts: 3, spacingMs: 300 })
32
34
  export const TASK_TODAY_PURGE_HORIZON_DAYS = 7
33
- export const FULL_DOMAINS = ['quilt', 'personal', 'hermit_crabs', 'sprocket_rocket'] as const
34
- export type TaskDomain = (typeof FULL_DOMAINS)[number]
35
+ /** Domains come from the user's own config and their own `operations/` tree, not
36
+ * from a list baked into the build. The previous constant here was one user's
37
+ * four business units, which is why a second COS install could not name
38
+ * anything that worked. `taskDomainNames` unions configured domains with every
39
+ * directory holding a `tasks.md`. */
40
+ export function taskDomains(): string[] {
41
+ return taskDomainNames(resolveCosOperationsDir())
42
+ }
43
+
44
+ export type TaskDomain = string
35
45
  export type TaskRunStatus = 'dispatching' | 'running' | 'done' | 'superseded' | 'failed' | 'orphaned'
36
46
  export const TASK_RUN_TERMINAL = new Set<TaskRunStatus>(['done', 'superseded', 'failed', 'orphaned'])
37
47
  export type TaskColumn = 'done' | 'running' | 'today' | 'carried' | 'scheduled' | 'inbox'
@@ -329,7 +339,7 @@ export async function loadDomainRows(domain: TaskDomain, day: string): Promise<B
329
339
  }
330
340
 
331
341
  export async function loadAllRows(day: string): Promise<BridgeTaskRow[]> {
332
- const groups = await Promise.all(FULL_DOMAINS.map(domain => loadDomainRows(domain, day)))
342
+ const groups = await Promise.all(taskDomains().map(domain => loadDomainRows(domain, day)))
333
343
  return groups.flat()
334
344
  }
335
345
 
@@ -420,8 +430,8 @@ export async function captureTask(body: {
420
430
  if (!pythonBridgeAvailable()) throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
421
431
  if (!body.captureId) throw new TaskRunError(400, 'capture_id_required', 'captureId is required.')
422
432
  if (!isClientJobId(body.captureId)) throw new TaskRunError(422, 'invalid_capture_id', 'captureId must be a UUID v4.')
423
- if (!FULL_DOMAINS.includes(body.domain as TaskDomain)) {
424
- throw new TaskRunError(400, 'invalid_domain', 'full domain required')
433
+ if (!taskDomains().includes(body.domain)) {
434
+ throw new TaskRunError(400, 'invalid_domain', `unknown domain: ${body.domain}`)
425
435
  }
426
436
  const paths = taskStorePaths()
427
437
  const seen = loadCapturesSeen(paths)
@@ -1,6 +1,8 @@
1
1
  import { Router } from 'express'
2
2
  import { queryJobCoordinator } from '../lib/query-job-runtime.js'
3
3
  import { runTaskNow } from '../lib/task-dispatcher.js'
4
+ import { domainAbbreviation } from '../lib/domains.js'
5
+ import { domainLabel } from '../lib/domain-label.js'
4
6
  import {
5
7
  TaskBridgeError,
6
8
  TaskRunError,
@@ -12,6 +14,7 @@ import {
12
14
  moveTask,
13
15
  saveDispatchCap,
14
16
  setTaskRunAt,
17
+ taskDomains,
15
18
  tasksGate,
16
19
  workBadgeCount,
17
20
  } from '../lib/task-store.js'
@@ -109,6 +112,21 @@ tasksRouter.patch('/tasks/dispatch-cap', (req, res) => {
109
112
  }
110
113
  })
111
114
 
115
+ /** The domain list a client should offer, so no picker hardcodes one user's
116
+ * business units. `abbr` is derived server-side so every surface agrees on the
117
+ * badge; the two abbreviation maps that used to live in the app disagreed. */
118
+ tasksRouter.get('/domains', (_req, res) => {
119
+ try {
120
+ const names = taskDomains()
121
+ res.json({
122
+ domains: names.map(name => ({ name, abbr: domainAbbreviation(name), label: domainLabel(name) })),
123
+ gate: tasksGate(),
124
+ })
125
+ } catch (error) {
126
+ sendError(res, error)
127
+ }
128
+ })
129
+
112
130
  tasksRouter.get('/tasks', async (req, res) => {
113
131
  try {
114
132
  const column = typeof req.query.column === 'string' ? req.query.column : undefined