@gotcos/glasses-server 6.44.0 → 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,20 @@
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
+
10
+ ## 6.44.1
11
+
12
+ Board and lens titles are readable.
13
+
14
+ - `projectRow` and `projectTaskRun` sliced the raw `tasks.md` line at 44 characters, so a title kept its markdown and was cut mid-word: 119 of the 201 rows served today began with an unbalanced `**`, and one row read `**Provide data migration process docs by ind`. Both now go through `taskTitle`, which strips code spans, links and emphasis first — buying back the characters the asterisks were spending — then cuts on a word boundary and marks the cut. Verified over all 312 live task lines: none over the cap, none with a stray asterisk, none empty.
15
+ - Underscore emphasis is anchored, so `cos_python` and `hermit_crabs` survive; only `_emphasis_` is stripped.
16
+ - `TASK_TITLE_MAX` is exported so tests pin the real cap instead of a copy of it.
17
+
1
18
  ## 6.44.0
2
19
 
3
20
  Task store: one COS `tasks.md` contract with a durable lock, columns, and a run ledger.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.0",
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": {
@@ -50,7 +50,9 @@
50
50
  "url": "git+https://github.com/ukaoma/cos-glasses-server.git"
51
51
  },
52
52
  "homepage": "https://www.gotcos.com",
53
- "bugs": { "url": "https://github.com/ukaoma/cos-glasses-server/issues" },
53
+ "bugs": {
54
+ "url": "https://github.com/ukaoma/cos-glasses-server/issues"
55
+ },
54
56
  "publishConfig": {
55
57
  "access": "public"
56
58
  },
@@ -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,10 +339,38 @@ 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
 
346
+ /** Board and lens titles come from a raw tasks.md line, which carries markdown
347
+ * emphasis and is usually far longer than a row. Slicing the raw line cut words
348
+ * and left unbalanced `**` on 119 of 201 live rows, so a row read
349
+ * "**Provide data migration process docs by ind". Strip the markup first (which
350
+ * buys back the four characters the asterisks were spending), then cut on a word
351
+ * boundary. The cap is exported so tests pin the real value rather than a copy. */
352
+ export const TASK_TITLE_MAX = 44
353
+
354
+ export function taskTitle(line: string): string {
355
+ const plain = line
356
+ .replace(/`([^`]*)`/g, '$1')
357
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
358
+ // Underscore emphasis must be anchored: a bare `_` between word characters is
359
+ // an identifier (cos_python, hermit_crabs), not markup, and stripping it
360
+ // corrupts the row. Asterisks need no such care, so one catch-all below clears
361
+ // them whether the pair is balanced or not — and after a slice, it often is not.
362
+ .replace(/(^|[\s(])_([^_]+)_(?=[\s).,;:!?]|$)/g, '$1$2')
363
+ .replace(/\*+/g, '')
364
+ .replace(/\s+/g, ' ')
365
+ .trim()
366
+ if (plain.length <= TASK_TITLE_MAX) return plain
367
+ const cut = plain.slice(0, TASK_TITLE_MAX - 1)
368
+ const space = cut.lastIndexOf(' ')
369
+ // Only honour a word boundary that is not so early it throws the title away.
370
+ const body = space > TASK_TITLE_MAX * 0.6 ? cut.slice(0, space) : cut
371
+ return `${body.replace(/[\s\u2014\u2013,;:.-]+$/, '')}\u2026`
372
+ }
373
+
336
374
  export function projectRow(
337
375
  row: BridgeTaskRow,
338
376
  ledger: readonly TaskRun[],
@@ -350,7 +388,7 @@ export function projectRow(
350
388
  id: row.id,
351
389
  ref: row.ref,
352
390
  domain: row.domain,
353
- title: row.description.slice(0, 44),
391
+ title: taskTitle(row.description),
354
392
  column: col,
355
393
  priority: row.priority,
356
394
  ...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
@@ -392,8 +430,8 @@ export async function captureTask(body: {
392
430
  if (!pythonBridgeAvailable()) throw new TaskRunError(503, 'cos_pipeline_not_configured', 'COS pipeline is not configured.')
393
431
  if (!body.captureId) throw new TaskRunError(400, 'capture_id_required', 'captureId is required.')
394
432
  if (!isClientJobId(body.captureId)) throw new TaskRunError(422, 'invalid_capture_id', 'captureId must be a UUID v4.')
395
- if (!FULL_DOMAINS.includes(body.domain as TaskDomain)) {
396
- 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}`)
397
435
  }
398
436
  const paths = taskStorePaths()
399
437
  const seen = loadCapturesSeen(paths)
@@ -502,7 +540,7 @@ export function projectTaskRun(
502
540
  run: TaskRun,
503
541
  snapshot?: { status: string; completedAt?: string } | null,
504
542
  ): TaskRunView {
505
- const title = run.line.slice(0, 44)
543
+ const title = taskTitle(run.line)
506
544
  const base = {
507
545
  id: run.id,
508
546
  kind: 'task' as const,
@@ -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