@gotcos/glasses-server 6.44.3 → 6.44.5

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,55 @@
1
+ ## 6.44.5
2
+
3
+ Recent learning and the knowledge graph, read-only, for COS Control.
4
+
5
+ - `GET /api/context/status` also carries `learning` and `graph` blocks. They come
6
+ from a second bridge call (`context-learning-graph-status`, 1.5 s budget) made
7
+ alongside the existing one with `Promise.allSettled`, so a bridge that predates
8
+ the command, times out, or answers `{ error }` leaves `memory` and `threads`
9
+ exactly as they were and the two blocks absent. Nothing an existing client
10
+ reads has changed; a client that wants the blocks checks for them.
11
+ - Eight read routes: `GET /api/context/learning` (cursor-paged events with a
12
+ per-store coverage map), `GET /api/context/learning/status`,
13
+ `GET /api/context/learning/:id`, `GET /api/context/graph/status`,
14
+ `GET /api/context/graph/search`, `GET /api/context/graph/entity`,
15
+ `GET /api/context/graph/passages`, and one `POST /api/context/graph/index`
16
+ that answers 202 and only asks the bridge to start a detached index build
17
+ (`graph-index-build --reason control`). The HTTP handler never runs
18
+ `--build-index`, `--apply-curation`, `--process-queue` or `graph-sync`; a
19
+ source test pins that. Every route sits behind the API token like the rest of
20
+ `/api`, answers `Cache-Control: private, no-store`, and 503s with the bridge
21
+ state when no pipeline is configured.
22
+ - Bodies are allowlisted before they leave: counts pass only as integers, event
23
+ ids must match `evt_` plus sixteen hex characters, unknown fields and local
24
+ paths never pass, and an `{ error }` from the bridge becomes a status (404 for
25
+ `*_not_found`, 400 for `invalid_*`, 503 otherwise) rather than a 200. Found
26
+ while writing the tests: the passages shape always carries an `error` key,
27
+ `null` on success, and the first draft read the key alone as a failure.
28
+ - Caps are the bridge's, restated at the edge: 50 events per page, 30 search
29
+ hits, 5 passages, a 200-character entity id, 160-character query.
30
+ - Older servers 404 these paths, which is how a client tells 6.44.5 apart from
31
+ what it had. A second Mac still on 6.44.2 picks up 6.44.3 and 6.44.4 with this
32
+ install; nothing in those two needs a step in between.
33
+
34
+ ## 6.44.4
35
+
36
+ Stage, a finish line, and no dispatch without one.
37
+
38
+ - A task row carries `stage` (planning, active or review) and `doneWhen`. Both
39
+ live in the tasks.md line, not a sidecar: the task id is a sha256 of the
40
+ normalized description, so an id-keyed store would orphan itself the first time
41
+ the text was edited. An unmarked row reads as planning, so nothing needed
42
+ migrating.
43
+ - `PATCH /api/tasks/:id` accepts `stage` and `doneWhen`.
44
+ - **`runTaskNow` refuses a task with no finish line**, `409 done_when_required`,
45
+ at the single choke point every dispatch passes through. An agent sent at a
46
+ task with no definition of done cannot succeed at it and cannot be judged to
47
+ have failed either.
48
+ - Fixes a round-trip bug older than this release: the writer joins end-of-line
49
+ markers with spaces while the reader's regex allowed none between them, so a
50
+ row carrying both a run time and an agent marker silently lost the run time on
51
+ every read.
52
+
1
53
  ## 6.44.3
2
54
 
3
55
  A task row carries enough to show and edit it.
package/README.md CHANGED
@@ -419,6 +419,16 @@ complete setup. The file tier is read from the code path taken only when no
419
419
  bridge is configured, so adding it cannot change the behaviour of an install that
420
420
  already has one.
421
421
 
422
+ Since 6.44.5 the bridge tier also serves recent learning and the knowledge graph,
423
+ read-only: `/api/context/learning` (events, cursor-paged, with a per-store
424
+ coverage map), `/api/context/learning/status`, `/api/context/learning/:id`, and
425
+ `/api/context/graph/{status,search,entity,passages}`, plus `POST
426
+ /api/context/graph/index`, which only asks the pipeline to start a detached index
427
+ build and answers 202. Nothing in the file tier can answer these, so they return
428
+ 503 with the bridge state there; older servers 404 them, which is how a client
429
+ tells the versions apart. `/api/context/status` carries `learning` and `graph`
430
+ blocks when the bridge can produce them and omits them otherwise.
431
+
422
432
  The API is read-only in both tiers. Standalone installs with neither a bridge nor
423
433
  a notes folder report the feature as unavailable without affecting messages,
424
434
  meetings, transcription, or agents.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.44.3",
3
+ "version": "6.44.5",
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": {
@@ -365,6 +365,423 @@ export interface ContextBrowserStatus {
365
365
  source?: 'bridge' | 'files'
366
366
  memory: { available: boolean; total: number; state: string; reason?: string }
367
367
  threads: { available: boolean; total: number; active: number; stale: number; resolved: number; state: string; reason?: string }
368
+ /** Recent learning / To review, served by learning_bridge.py since 6.44.5. Absent on older bridges. */
369
+ learning?: LearningBlock
370
+ /** Knowledge graph status, served by learning_bridge.py since 6.44.5. Absent on older bridges. */
371
+ graph?: GraphBlock
372
+ }
373
+
374
+ export interface LearningBlock {
375
+ available: boolean
376
+ state: string
377
+ count?: number
378
+ to_review?: { patterns?: number; task_proposals?: number }
379
+ last_ts?: string
380
+ orphan_decisions?: number
381
+ stores_readable?: number
382
+ }
383
+
384
+ export interface GraphBlock {
385
+ available: boolean
386
+ state: string
387
+ entities?: number
388
+ relationships?: number
389
+ source_updated_at?: string
390
+ index_state?: string
391
+ index_built_at?: string
392
+ queue_pending?: number
393
+ owner_host?: string
394
+ is_owner?: boolean
395
+ replica?: boolean
396
+ processor_state?: string
397
+ lock_state?: string
398
+ }
399
+
400
+ function asRecord(value: unknown): Record<string, unknown> | null {
401
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null
402
+ }
403
+
404
+ /**
405
+ * A count is carried ONLY when the bridge sent a clean integer. finiteInteger()
406
+ * would coerce '1', 1.5 and true to a number and default a missing field to 0,
407
+ * which is how an absent block would read as "0 to review".
408
+ */
409
+ function integerOrAbsent(value: unknown): number | undefined {
410
+ return Number.isInteger(value) && (value as number) >= 0 ? value as number : undefined
411
+ }
412
+
413
+ function isoOrAbsent(value: unknown): string | undefined {
414
+ if (typeof value !== 'string' || !value) return undefined
415
+ const cleaned = cleanContextText(value, 40)
416
+ return Number.isFinite(Date.parse(cleaned)) ? cleaned : undefined
417
+ }
418
+
419
+ function stringOrAbsent(value: unknown, limit: number): string | undefined {
420
+ if (typeof value !== 'string' || !value) return undefined
421
+ const cleaned = cleanContextText(value, limit)
422
+ return cleaned || undefined
423
+ }
424
+
425
+ function booleanOrAbsent(value: unknown): boolean | undefined {
426
+ return typeof value === 'boolean' ? value : undefined
427
+ }
428
+
429
+ function defined<T extends Record<string, unknown>>(record: T): T {
430
+ for (const key of Object.keys(record)) if (record[key] === undefined) delete record[key]
431
+ return record
432
+ }
433
+
434
+ export function normalizeLearningBlock(value: unknown): LearningBlock | null {
435
+ const source = asRecord(value)
436
+ if (!source) return null
437
+ const review = asRecord(source.to_review)
438
+ const toReview = review ? defined({ patterns: integerOrAbsent(review.patterns), task_proposals: integerOrAbsent(review.task_proposals) }) : undefined
439
+ return defined({
440
+ available: source.available === true,
441
+ state: cleanContextText(source.state, 64) || (source.available === true ? 'ready' : 'unavailable'),
442
+ count: integerOrAbsent(source.count),
443
+ to_review: toReview && Object.keys(toReview).length ? toReview : undefined,
444
+ last_ts: isoOrAbsent(source.last_ts),
445
+ orphan_decisions: integerOrAbsent(source.orphan_decisions),
446
+ stores_readable: integerOrAbsent(source.stores_readable),
447
+ }) as LearningBlock
448
+ }
449
+
450
+ export function normalizeGraphBlock(value: unknown): GraphBlock | null {
451
+ const source = asRecord(value)
452
+ if (!source) return null
453
+ return defined({
454
+ available: source.available === true,
455
+ state: cleanContextText(source.state, 64) || (source.available === true ? 'ready' : 'unavailable'),
456
+ entities: integerOrAbsent(source.entities),
457
+ relationships: integerOrAbsent(source.relationships),
458
+ source_updated_at: isoOrAbsent(source.source_updated_at),
459
+ index_state: stringOrAbsent(source.index_state, 32),
460
+ index_built_at: isoOrAbsent(source.index_built_at),
461
+ queue_pending: integerOrAbsent(source.queue_pending),
462
+ owner_host: stringOrAbsent(source.owner_host, 128),
463
+ is_owner: booleanOrAbsent(source.is_owner),
464
+ replica: booleanOrAbsent(source.replica),
465
+ processor_state: stringOrAbsent(source.processor_state, 32),
466
+ lock_state: stringOrAbsent(source.lock_state, 32),
467
+ }) as GraphBlock
468
+ }
469
+
470
+ // ── Recent learning payloads (GET /context/learning, /context/learning/:id) ──
471
+
472
+ export const LEARNING_EVENT_ID_PATTERN = /^evt_[a-f0-9]{16}$/
473
+ export const LEARNING_EVENT_TYPES = new Set(['captured', 'proposed', 'promotable', 'saved', 'retrieved', 'used', 'checked', 'dismissed', 'reverted', 'reopened', 'consolidated', 'previewed'])
474
+ const LEARNING_LIST_LIMIT = 50
475
+ const LEARNING_DETAIL_KEYS = ['shape', 'task', 'kind', 'layer', 'date', 'future', 'logged_times', 'memory_type', 'capture', 'source', 'content', 'before', 'after', 'rule', 'status', 'occurrences', 'threshold', 'entry', 'truncated'] as const
476
+
477
+ export interface LearningEvent {
478
+ event_id: string
479
+ lesson_id: string | null
480
+ event_type: string
481
+ ts: string
482
+ store: string
483
+ title: string
484
+ scope: string
485
+ category: string | null
486
+ engine: string
487
+ target: { kind: string | null; id: string; version: string | null }
488
+ applies_to: Array<{ kind: string; name: string; cadence: string | null; evidence: string }>
489
+ source_refs: Array<{ kind: string; id: string; excerpt: string }>
490
+ prior_event_id: string | null
491
+ outcome: { name: string; result: string; evaluator: string; ts: string | null } | null
492
+ provenance: string
493
+ ordinal?: number
494
+ }
495
+
496
+ function normalizeLearningEvent(value: unknown, excerptLimit: number): LearningEvent | null {
497
+ const source = asRecord(value)
498
+ if (!source || typeof source.event_id !== 'string' || !LEARNING_EVENT_ID_PATTERN.test(source.event_id)) return null
499
+ const eventType = cleanContextText(source.event_type, 32)
500
+ if (!LEARNING_EVENT_TYPES.has(eventType)) return null
501
+ const ts = isoOrAbsent(source.ts)
502
+ if (!ts) return null
503
+ const target = asRecord(source.target) ?? {}
504
+ const outcome = asRecord(source.outcome)
505
+ const appliesTo = Array.isArray(source.applies_to) ? source.applies_to.slice(0, 10).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => ({
506
+ kind: cleanContextText(r.kind, 16), name: cleanContextText(r.name, 160),
507
+ cadence: stringOrAbsent(r.cadence, 120) ?? null, evidence: cleanContextText(r.evidence, 240),
508
+ })) : []
509
+ const refs = Array.isArray(source.source_refs) ? source.source_refs.slice(0, 10).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => ({
510
+ kind: cleanContextText(r.kind, 32), id: cleanContextText(r.id, 200), excerpt: cleanContextText(r.excerpt, excerptLimit),
511
+ })) : []
512
+ const event: LearningEvent = {
513
+ event_id: source.event_id,
514
+ lesson_id: stringOrAbsent(source.lesson_id, 200) ?? null,
515
+ event_type: eventType,
516
+ ts,
517
+ store: cleanContextText(source.store, 40),
518
+ title: cleanContextText(source.title, 160),
519
+ scope: cleanContextText(source.scope, 24) || 'unknown',
520
+ category: stringOrAbsent(source.category, 160) ?? null,
521
+ engine: cleanContextText(source.engine, 24) || 'unknown',
522
+ target: { kind: stringOrAbsent(target.kind, 24) ?? null, id: cleanContextText(target.id, 200), version: stringOrAbsent(target.version, 64) ?? null },
523
+ applies_to: appliesTo,
524
+ source_refs: refs,
525
+ prior_event_id: typeof source.prior_event_id === 'string' && LEARNING_EVENT_ID_PATTERN.test(source.prior_event_id) ? source.prior_event_id : null,
526
+ outcome: outcome ? {
527
+ name: cleanContextText(outcome.name, 160), result: cleanContextText(outcome.result, 24),
528
+ evaluator: cleanContextText(outcome.evaluator, 64), ts: isoOrAbsent(outcome.ts) ?? null,
529
+ } : null,
530
+ provenance: cleanContextText(source.provenance, 24) || 'unavailable',
531
+ }
532
+ const ordinal = integerOrAbsent(source.ordinal)
533
+ if (ordinal !== undefined) event.ordinal = ordinal
534
+ return event
535
+ }
536
+
537
+ export interface LearningCoverage { [store: string]: { state: string; count: number; detail?: string } }
538
+
539
+ export function normalizeLearningCoverage(value: unknown): LearningCoverage {
540
+ const source = asRecord(value) ?? {}
541
+ const out: LearningCoverage = {}
542
+ for (const [store, raw] of Object.entries(source).slice(0, 16)) {
543
+ const entry = asRecord(raw)
544
+ if (!entry) continue
545
+ const key = cleanContextText(store, 40)
546
+ if (!key) continue
547
+ out[key] = defined({
548
+ state: cleanContextText(entry.state, 32) || 'unavailable',
549
+ count: integerOrAbsent(entry.count) ?? 0,
550
+ detail: stringOrAbsent(entry.detail, 240),
551
+ }) as { state: string; count: number; detail?: string }
552
+ }
553
+ return out
554
+ }
555
+
556
+ export function normalizeLearningEvents(value: unknown, limit: number): {
557
+ events: LearningEvent[]; total: number; next_cursor: { since_ts: string; since_event_id: string } | null; coverage: LearningCoverage
558
+ } {
559
+ const source = asRecord(value) ?? {}
560
+ const cap = Math.max(1, Math.min(limit, LEARNING_LIST_LIMIT))
561
+ const events = (Array.isArray(source.events) ? source.events : []).slice(0, cap)
562
+ .map(item => normalizeLearningEvent(item, 240)).filter((e): e is LearningEvent => !!e)
563
+ const cursor = asRecord(source.next_cursor)
564
+ const sinceTs = cursor ? isoOrAbsent(cursor.since_ts) : undefined
565
+ const sinceId = cursor && typeof cursor.since_event_id === 'string' && LEARNING_EVENT_ID_PATTERN.test(cursor.since_event_id) ? cursor.since_event_id : undefined
566
+ return {
567
+ events,
568
+ total: integerOrAbsent(source.total) ?? events.length,
569
+ next_cursor: sinceTs && sinceId ? { since_ts: sinceTs, since_event_id: sinceId } : null,
570
+ coverage: normalizeLearningCoverage(source.coverage),
571
+ }
572
+ }
573
+
574
+ export function normalizeLearningEventDetail(value: unknown): (LearningEvent & { detail: Record<string, unknown> }) | null {
575
+ const event = normalizeLearningEvent(value, 1200)
576
+ if (!event) return null
577
+ const raw = asRecord((value as Record<string, unknown>).detail) ?? {}
578
+ const detail: Record<string, unknown> = {}
579
+ for (const key of LEARNING_DETAIL_KEYS) {
580
+ const item = raw[key]
581
+ if (item === undefined || item === null) continue
582
+ if (key === 'bodies' as string) continue
583
+ if (typeof item === 'boolean') detail[key] = item
584
+ else if (Number.isInteger(item)) detail[key] = item
585
+ else if (typeof item === 'string') detail[key] = cleanContextText(item, 1200)
586
+ }
587
+ if (Array.isArray(raw.bodies)) detail.bodies = raw.bodies.slice(0, 12).map(body => cleanContextText(body, 1200))
588
+ return { ...event, detail }
589
+ }
590
+
591
+ export interface LearningStatus {
592
+ stores: LearningCoverage & { [store: string]: { state: string; count: number; detail?: string; readable?: boolean; last_ts?: string } }
593
+ to_review: { count: number; pattern: number; 'task-proposal': number }
594
+ orphan_decisions: number
595
+ no_store_active: boolean
596
+ engines: string[]
597
+ counts_by_type: Record<string, number>
598
+ }
599
+
600
+ export function normalizeLearningStatus(value: unknown): LearningStatus {
601
+ const source = asRecord(value) ?? {}
602
+ const stores: LearningStatus['stores'] = {}
603
+ for (const [store, raw] of Object.entries(asRecord(source.stores) ?? {}).slice(0, 16)) {
604
+ const entry = asRecord(raw)
605
+ const key = cleanContextText(store, 40)
606
+ if (!entry || !key) continue
607
+ stores[key] = defined({
608
+ state: cleanContextText(entry.state, 32) || 'unavailable',
609
+ count: integerOrAbsent(entry.count) ?? 0,
610
+ readable: booleanOrAbsent(entry.readable),
611
+ last_ts: isoOrAbsent(entry.last_ts),
612
+ }) as LearningStatus['stores'][string]
613
+ }
614
+ const review = asRecord(source.to_review) ?? {}
615
+ const counts: Record<string, number> = {}
616
+ for (const [type, raw] of Object.entries(asRecord(source.counts_by_type) ?? {})) {
617
+ if (LEARNING_EVENT_TYPES.has(type) && Number.isInteger(raw)) counts[type] = raw as number
618
+ }
619
+ return {
620
+ stores,
621
+ to_review: { count: integerOrAbsent(review.count) ?? 0, pattern: integerOrAbsent(review.pattern) ?? 0, 'task-proposal': integerOrAbsent(review['task-proposal']) ?? 0 },
622
+ orphan_decisions: integerOrAbsent(source.orphan_decisions) ?? 0,
623
+ no_store_active: source.no_store_active === true,
624
+ engines: stringList(source.engines, 8, 24),
625
+ counts_by_type: counts,
626
+ }
627
+ }
628
+
629
+ // ── Knowledge graph payloads (GET /context/graph/*) ──
630
+
631
+ export const GRAPH_ENTITY_ID_LIMIT = 200
632
+ export const GRAPH_INDEX_STATES = new Set(['fresh', 'stale', 'missing', 'source_missing'])
633
+
634
+ export function normalizeIndexReceipt(value: unknown): Record<string, unknown> | null {
635
+ const source = asRecord(value)
636
+ if (!source) return null
637
+ return defined({
638
+ state: stringOrAbsent(source.state, 16) ?? null,
639
+ started_at: isoOrAbsent(source.started_at) ?? null,
640
+ ended_at: isoOrAbsent(source.ended_at) ?? null,
641
+ pid: integerOrAbsent(source.pid) ?? null,
642
+ host: stringOrAbsent(source.host, 128) ?? null,
643
+ reason: stringOrAbsent(source.reason, 64) ?? null,
644
+ error: stringOrAbsent(source.error, 240) ?? null,
645
+ wall_s: typeof source.wall_s === 'number' && Number.isFinite(source.wall_s) ? source.wall_s : null,
646
+ peak_rss_mb: typeof source.peak_rss_mb === 'number' && Number.isFinite(source.peak_rss_mb) ? source.peak_rss_mb : null,
647
+ node_count: integerOrAbsent(source.node_count) ?? null,
648
+ edge_count: integerOrAbsent(source.edge_count) ?? null,
649
+ build_seq: integerOrAbsent(source.build_seq) ?? null,
650
+ degraded: source.degraded === true,
651
+ })
652
+ }
653
+
654
+ export function normalizeGraphStatus(value: unknown): Record<string, unknown> {
655
+ const source = asRecord(value) ?? {}
656
+ const src = asRecord(source.source) ?? {}
657
+ const queue = asRecord(source.queue) ?? {}
658
+ const budget = asRecord(source.budget) ?? {}
659
+ const lock = asRecord(source.lock) ?? {}
660
+ const processor = asRecord(source.processor) ?? {}
661
+ const indexState = stringOrAbsent(source.index_state, 32)
662
+ return {
663
+ entities: integerOrAbsent(source.entities) ?? null,
664
+ relationships: integerOrAbsent(source.relationships) ?? null,
665
+ source_updated_at: isoOrAbsent(source.source_updated_at) ?? null,
666
+ index_built_at: isoOrAbsent(source.index_built_at) ?? null,
667
+ index_state: indexState && GRAPH_INDEX_STATES.has(indexState) ? indexState : 'missing',
668
+ index_degraded: source.index_degraded === true,
669
+ build: normalizeIndexReceipt(source.build),
670
+ source: {
671
+ owner_host: stringOrAbsent(src.owner_host, 128) ?? null,
672
+ this_host: stringOrAbsent(src.this_host, 128) ?? null,
673
+ is_owner: src.is_owner === true,
674
+ owner_state: stringOrAbsent(src.owner_state, 16) ?? 'unset',
675
+ replica: src.replica === true,
676
+ source_sha256_prefix: stringOrAbsent(src.source_sha256_prefix, 16) ?? null,
677
+ index_built_on_host: stringOrAbsent(src.index_built_on_host, 128) ?? null,
678
+ index_host_mismatch: src.index_host_mismatch === true,
679
+ },
680
+ queue: {
681
+ live_total: integerOrAbsent(queue.live_total) ?? null,
682
+ pending: integerOrAbsent(queue.pending) ?? null,
683
+ failed: integerOrAbsent(queue.failed) ?? null,
684
+ deferred: integerOrAbsent(queue.deferred) ?? null,
685
+ oldest_pending_at: isoOrAbsent(queue.oldest_pending_at) ?? null,
686
+ oldest_pending_age_s: integerOrAbsent(queue.oldest_pending_age_s) ?? null,
687
+ missing_sources: integerOrAbsent(queue.missing_sources) ?? null,
688
+ conflict_copies: integerOrAbsent(queue.conflict_copies) ?? null,
689
+ },
690
+ budget: { used: integerOrAbsent(budget.used) ?? null, cap: integerOrAbsent(budget.cap) ?? null },
691
+ lock: { state: stringOrAbsent(lock.state, 16) ?? 'unknown', owner_pid: integerOrAbsent(lock.owner_pid) ?? null, error: stringOrAbsent(lock.error, 64) ?? null },
692
+ last_run: normalizeIndexReceipt(source.last_run),
693
+ processor: {
694
+ state: stringOrAbsent(processor.state, 24) ?? 'none',
695
+ plist: processor.plist === true,
696
+ cadence_s: integerOrAbsent(processor.cadence_s) ?? null,
697
+ last_run_at: isoOrAbsent(processor.last_run_at) ?? null,
698
+ last_outcome: stringOrAbsent(processor.last_outcome, 24) ?? null,
699
+ },
700
+ }
701
+ }
702
+
703
+ export function normalizeGraphSearch(value: unknown, limit: number): Record<string, unknown> {
704
+ const source = asRecord(value) ?? {}
705
+ const items = (Array.isArray(source.items) ? source.items : []).slice(0, Math.max(1, Math.min(limit, 30))).map(asRecord)
706
+ .filter((r): r is Record<string, unknown> => !!r && typeof r.id === 'string')
707
+ .map(r => ({ id: cleanContextText(r.id, GRAPH_ENTITY_ID_LIMIT), type: stringOrAbsent(r.type, 40) ?? null, degree: integerOrAbsent(r.degree) ?? 0, description: cleanContextText(r.description, 240) }))
708
+ return {
709
+ items,
710
+ total: integerOrAbsent(source.total) ?? items.length,
711
+ scope: 'full-index',
712
+ index_built_at: isoOrAbsent(source.index_built_at) ?? null,
713
+ index_state: stringOrAbsent(source.index_state, 32) ?? 'missing',
714
+ matcher: stringOrAbsent(source.matcher, 16) ?? null,
715
+ window: integerOrAbsent(source.window) ?? null,
716
+ offset: integerOrAbsent(source.offset) ?? 0,
717
+ limit: integerOrAbsent(source.limit) ?? items.length,
718
+ }
719
+ }
720
+
721
+ export function normalizeGraphEntity(value: unknown): Record<string, unknown> | null {
722
+ const source = asRecord(value)
723
+ if (!source || source.found !== true || typeof source.id !== 'string') return null
724
+ const edges = (Array.isArray(source.edges) ? source.edges : []).slice(0, 30).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => ({
725
+ source: cleanContextText(r.source, GRAPH_ENTITY_ID_LIMIT), target: cleanContextText(r.target, GRAPH_ENTITY_ID_LIMIT),
726
+ weight: typeof r.weight === 'number' && Number.isFinite(r.weight) ? r.weight : null, description: cleanContextText(r.description, 240),
727
+ }))
728
+ const neighbors = (Array.isArray(source.neighbors) ? source.neighbors : []).slice(0, 30).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => ({
729
+ id: cleanContextText(r.id, GRAPH_ENTITY_ID_LIMIT), type: stringOrAbsent(r.type, 40) ?? null, degree: integerOrAbsent(r.degree) ?? 0,
730
+ }))
731
+ return {
732
+ found: true,
733
+ id: cleanContextText(source.id, GRAPH_ENTITY_ID_LIMIT),
734
+ type: stringOrAbsent(source.type, 40) ?? null,
735
+ degree: integerOrAbsent(source.degree) ?? 0,
736
+ description: cleanContextText(source.description, 1200),
737
+ description_length: integerOrAbsent(source.description_length) ?? null,
738
+ descriptions: stringList(source.descriptions, 12, 1200),
739
+ created_at: integerOrAbsent(source.created_at) ?? null,
740
+ first_seen_build: integerOrAbsent(source.first_seen_build) ?? null,
741
+ edges, neighbors,
742
+ total_relationships: integerOrAbsent(source.total_relationships) ?? edges.length,
743
+ offset: integerOrAbsent(source.offset) ?? 0,
744
+ limit: integerOrAbsent(source.limit) ?? edges.length,
745
+ source_status: stringOrAbsent(source.source_status, 16) ?? 'unresolved',
746
+ source_count: integerOrAbsent(source.source_count) ?? 0,
747
+ source_resolved: integerOrAbsent(source.source_resolved) ?? 0,
748
+ index_built_at: isoOrAbsent(source.index_built_at) ?? null,
749
+ index_state: stringOrAbsent(source.index_state, 32) ?? 'missing',
750
+ }
751
+ }
752
+
753
+ export function normalizeGraphPassages(value: unknown): Record<string, unknown> {
754
+ const source = asRecord(value) ?? {}
755
+ const items = (Array.isArray(source.items) ? source.items : []).slice(0, 5).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => {
756
+ const src = asRecord(r.source) ?? {}
757
+ return {
758
+ chunk_id: cleanContextText(r.chunk_id, 80), doc_id: stringOrAbsent(r.doc_id, 80) ?? null,
759
+ order: integerOrAbsent(r.order) ?? null, excerpt: cleanContextText(r.excerpt, 1200),
760
+ source: {
761
+ status: stringOrAbsent(src.status, 16) ?? 'unresolved', key: stringOrAbsent(src.key, 200) ?? null,
762
+ title: stringOrAbsent(src.title, 200) ?? null, date: stringOrAbsent(src.date, 40) ?? null, summary: stringOrAbsent(src.summary, 120) ?? null,
763
+ },
764
+ }
765
+ })
766
+ return {
767
+ items,
768
+ total: integerOrAbsent(source.total) ?? items.length,
769
+ index_state: stringOrAbsent(source.index_state, 32) ?? null,
770
+ index_built_at: isoOrAbsent(source.index_built_at) ?? null,
771
+ fallback: stringOrAbsent(source.fallback, 24) ?? null,
772
+ unavailable: stringList(source.unavailable, 8, 64),
773
+ note: stringOrAbsent(source.note, 240) ?? null,
774
+ }
775
+ }
776
+
777
+ export function normalizeIndexBuildKickoff(value: unknown): { started: boolean; already_running: boolean; pid: number | null; receipt: Record<string, unknown> | null } {
778
+ const source = asRecord(value) ?? {}
779
+ return {
780
+ started: source.started === true,
781
+ already_running: source.already_running === true,
782
+ pid: integerOrAbsent(source.pid) ?? null,
783
+ receipt: normalizeIndexReceipt(source.receipt),
784
+ }
368
785
  }
369
786
 
370
787
  export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
@@ -390,6 +807,10 @@ export function normalizeContextBrowserStatus(value: unknown): ContextBrowserSta
390
807
  const incompatibleState = protocolCompatible ? '' : 'bridge_outdated'
391
808
  const memoryState = incompatibleState || cleanState(memory.state, memoryAvailable ? 'ready' : 'unavailable')
392
809
  const threadState = incompatibleState || cleanState(threads.state, threadsAvailable ? 'ready' : 'unavailable')
810
+ // The learning and graph blocks (6.44.5) sit behind the SAME protocol gate; an
811
+ // absent block stays absent so the two toEqual pins on older payloads hold.
812
+ const learning = protocolCompatible ? normalizeLearningBlock(source.learning) : null
813
+ const graph = protocolCompatible ? normalizeGraphBlock(source.graph) : null
393
814
  return {
394
815
  available: protocolCompatible && source.available === true,
395
816
  protocol,
@@ -420,5 +841,7 @@ export function normalizeContextBrowserStatus(value: unknown): ContextBrowserSta
420
841
  ? { reason: 'bridge_outdated' }
421
842
  : threads.reason ? { reason: cleanState(threads.reason, threadState) } : {}),
422
843
  },
844
+ ...(learning ? { learning } : {}),
845
+ ...(graph ? { graph } : {}),
423
846
  }
424
847
  }
@@ -38,6 +38,25 @@ if (!COS_SCRIPTS_DIR) {
38
38
  export const PYTHON_BIN: string | null = COS_SCRIPTS_DIR ? resolve(COS_SCRIPTS_DIR, 'venv/bin/python3') : null
39
39
  const BRIDGE_SCRIPT: string | null = COS_SCRIPTS_DIR ? resolve(COS_SCRIPTS_DIR, 'cos_api_bridge.py') : null
40
40
 
41
+ /**
42
+ * The bridge commands served by operations/scripts/learning_bridge.py (COS
43
+ * Control Memories, Phase 0.3). Every name here has an explicit file-tier case
44
+ * in `standaloneNoop` below, and python-bridge-files.test.ts pins this list
45
+ * against the Python bridge's `_LEARNING_COMMANDS` frozenset when that file is
46
+ * reachable. Grows in the same commit as each later command.
47
+ */
48
+ export const LEARNING_COMMANDS = [
49
+ 'context-learning-graph-status',
50
+ 'learning-events',
51
+ 'learning-event',
52
+ 'learning-status',
53
+ 'graph-status',
54
+ 'graph-search',
55
+ 'graph-entity',
56
+ 'graph-passages',
57
+ 'graph-index-build',
58
+ ] as const
59
+
41
60
  // The optional Python bridge is available only when the user points us at a real
42
61
  // COS pipeline that ships the venv + bridge script. Standalone installs never
43
62
  // have these, so callPython() degrades to a no-op.
@@ -186,6 +205,28 @@ function standaloneNoop(args: string[]): unknown {
186
205
  return { error: 'cos_pipeline_not_configured' }
187
206
  }
188
207
  case 'badges': return {}
208
+ // Learning / knowledge commands (COS Control Memories, Phase 0.3). These
209
+ // need the Python bridge; the file tier has no learning stores or graph
210
+ // index to serve, so each says so in the STRING form the memory routes
211
+ // read. LEARNING_COMMANDS above is pinned against `_LEARNING_COMMANDS` in
212
+ // operations/scripts/cos_api_bridge.py by python-bridge-files.test.ts.
213
+ case 'context-learning-graph-status':
214
+ // The one learning command with a SHAPE: both blocks present and unavailable,
215
+ // so the /context/status merge path is exercised in the file tier too.
216
+ return {
217
+ learning: { available: false, state: 'cos_pipeline_not_configured' },
218
+ graph: { available: false, state: 'cos_pipeline_not_configured' },
219
+ protocol: 1,
220
+ }
221
+ case 'learning-events':
222
+ case 'learning-event':
223
+ case 'learning-status':
224
+ case 'graph-status':
225
+ case 'graph-search':
226
+ case 'graph-entity':
227
+ case 'graph-passages':
228
+ case 'graph-index-build':
229
+ return { error: 'cos_pipeline_not_configured' }
189
230
  case 'task-rows':
190
231
  case 'task-capture':
191
232
  case 'task-set-run-at':
@@ -150,6 +150,19 @@ function classifySubmitError(error: unknown): 'identity_conflict' | 'adopt' | 'f
150
150
  return 'transient'
151
151
  }
152
152
 
153
+ /** A task is dispatchable only once someone has said what finished looks like.
154
+ *
155
+ * Checked in THREE places on purpose, because there are two dispatch paths:
156
+ * runTaskNow refuses early so the caller gets a 409 before slots are taken,
157
+ * pickEligible skips the row so the timer does not retry it every tick, and
158
+ * mintRun is the fail-closed backstop — it is the one function BOTH paths call,
159
+ * which the earlier "one choke point" comment on runTaskNow wrongly claimed of
160
+ * runTaskNow itself. A due row with no finish line used to fire unattended.
161
+ */
162
+ function hasFinishLine(row: BridgeTaskRow): boolean {
163
+ return Boolean(row.done_when && row.done_when.trim())
164
+ }
165
+
153
166
  async function mintRun(
154
167
  deps: TaskDispatcherDeps,
155
168
  row: BridgeTaskRow,
@@ -159,6 +172,13 @@ async function mintRun(
159
172
  const config = configOf(deps)
160
173
  const clock = localClock(nowMs(deps), config.timezone)
161
174
  return serializeTaskWork(() => {
175
+ if (!hasFinishLine(row)) {
176
+ throw new TaskRunError(
177
+ 409,
178
+ 'done_when_required',
179
+ 'Say what done looks like before running this task.',
180
+ )
181
+ }
162
182
  const paths = pathsOf(deps)
163
183
  const ledger = loadTaskLedger(paths)
164
184
  if (ledger.some(run => run.taskId === row.id && run.day === clock.day && (run.status === 'dispatching' || run.status === 'running'))) {
@@ -284,6 +304,10 @@ function pickEligible(
284
304
  if (todayRuns.length >= capPerDay) return []
285
305
  return rows.filter(row => {
286
306
  if (row.archived || row.delegated || row.is_checked) return false
307
+ // A scheduled run is unattended, so it needs the finish line MORE than a
308
+ // manual one, not less. Skipped here rather than thrown so the timer does
309
+ // not burn a dispatch slot on it every tick.
310
+ if (!hasFinishLine(row)) return false
287
311
  if (!isCatchUpDue(row, now, tz, catchUpMinutes)) return false
288
312
  if (row.agent_state === 'running') return false
289
313
  const inDay = todayRuns.filter(run => run.taskId === row.id)
@@ -358,6 +382,17 @@ export async function runTaskNow(id: string, domain: string, injected?: TaskDisp
358
382
  if (row.agent_state === 'running') {
359
383
  throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
360
384
  }
385
+ // No finish line, no dispatch. An agent sent at a task with no definition of
386
+ // done cannot succeed at it and cannot be judged to have failed either. This
387
+ // is the EARLY refusal on the manual path only — mintRun is the backstop both
388
+ // paths share. See hasFinishLine.
389
+ if (!hasFinishLine(row)) {
390
+ throw new TaskRunError(
391
+ 409,
392
+ 'done_when_required',
393
+ 'Say what done looks like before running this task.',
394
+ )
395
+ }
361
396
  const runAt = parseRunAt(row.run_at)
362
397
  if (runAt && runAt.day > clock.day) {
363
398
  throw new TaskRunError(409, 'scheduled_future', 'Run now is not allowed on a future scheduled task.')
@@ -102,6 +102,12 @@ export interface BridgeTaskRow {
102
102
  agent_no: number | null
103
103
  section: string
104
104
  section_day: string | null
105
+ /** Workflow stage. null/absent means planning, the unmarked default.
106
+ * Optional so new server code against an older bridge degrades to planning
107
+ * rather than throwing during a partial deploy. */
108
+ stage?: 'planning' | 'active' | 'review' | null
109
+ /** The finish line. A dispatch is refused while this is empty. */
110
+ done_when?: string | null
105
111
  }
106
112
 
107
113
  export interface TaskFlags {
@@ -112,6 +118,10 @@ export interface TaskFlags {
112
118
  carriedOver: boolean
113
119
  }
114
120
 
121
+ /** Board stage. Planning is the default and carries no marker in tasks.md. */
122
+ export type TaskStage = 'planning' | 'active' | 'review'
123
+ export const TASK_STAGES: readonly TaskStage[] = ['planning', 'active', 'review']
124
+
115
125
  export interface TaskBoardRow {
116
126
  id: string
117
127
  ref: string
@@ -131,6 +141,9 @@ export interface TaskBoardRow {
131
141
  runAt?: string
132
142
  section: string
133
143
  sectionDay?: string
144
+ stage: TaskStage
145
+ /** Absent until someone says what finished looks like. Gates `run`. */
146
+ doneWhen?: string
134
147
  due: boolean
135
148
  missed: boolean
136
149
  failed: boolean
@@ -414,6 +427,8 @@ export function projectRow(
414
427
  ...(runAt ? { runAt: new Date(taskInstant(runAt.day, runAt.minutes, tz)).toISOString() } : {}),
415
428
  section: row.section,
416
429
  ...(row.section_day ? { sectionDay: row.section_day } : {}),
430
+ stage: row.stage ?? 'planning',
431
+ ...(row.done_when ? { doneWhen: row.done_when } : {}),
417
432
  ...mark,
418
433
  }
419
434
  }
@@ -486,6 +501,26 @@ export async function setTaskText(domain: string, id: string, text: string): Pro
486
501
  await withLockRetry(() => bridge(['task-set-text', domain, id], clean))
487
502
  }
488
503
 
504
+ /** Move a task between board stages. 'planning' clears the marker. */
505
+ export async function setTaskStage(domain: string, id: string, stage: TaskStage): Promise<void> {
506
+ if (!TASK_STAGES.includes(stage)) {
507
+ throw new TaskRunError(422, 'invalid_stage', `stage must be one of ${TASK_STAGES.join(', ')}`)
508
+ }
509
+ await withLockRetry(() => bridge(['task-set-stage', domain, id, stage === 'planning' ? '--clear' : stage]))
510
+ }
511
+
512
+ /** Set or clear the finish line. Empty clears it, which re-blocks `run`. */
513
+ export async function setTaskDoneWhen(domain: string, id: string, text: string): Promise<void> {
514
+ const clean = text.replace(/\s+/g, ' ').trim()
515
+ if (clean.length > 500) {
516
+ throw new TaskRunError(422, 'done_when_too_long', 'done_when must be 500 characters or fewer')
517
+ }
518
+ if (clean.includes('**')) {
519
+ throw new TaskRunError(422, 'invalid_done_when', 'done_when cannot contain ** markup')
520
+ }
521
+ await withLockRetry(() => bridge(['task-set-done-when', domain, id], clean))
522
+ }
523
+
489
524
  export async function moveTask(domain: string, id: string, section: string, nowMs = Date.now()): Promise<void> {
490
525
  const { clock } = briefContext(nowMs)
491
526
  const args = ['task-move', domain, id, '--section', section]
@@ -14,7 +14,17 @@ function contextConfigured(): boolean {
14
14
  return contextSourceAvailable() !== null
15
15
  }
16
16
  import {
17
+ GRAPH_ENTITY_ID_LIMIT,
18
+ LEARNING_EVENT_ID_PATTERN,
17
19
  MEMORY_ID_PATTERN,
20
+ normalizeGraphEntity,
21
+ normalizeGraphPassages,
22
+ normalizeGraphSearch,
23
+ normalizeGraphStatus,
24
+ normalizeIndexBuildKickoff,
25
+ normalizeLearningEventDetail,
26
+ normalizeLearningEvents,
27
+ normalizeLearningStatus,
18
28
  normalizeMemoryDetail,
19
29
  normalizeMemoryList,
20
30
  normalizeMemoryOverview,
@@ -34,15 +44,209 @@ memoryRouter.get('/context/status', async (_req, res) => {
34
44
  }))
35
45
  return
36
46
  }
37
- try {
38
- const data = await callPython(['context-status'], 8_000)
39
- res.json(normalizeContextBrowserStatus(data))
40
- } catch {
47
+ // Both bridge calls are created in ONE synchronous statement so the second
48
+ // never extends the wall time past the first (8 s worst case, not 9.5 s,
49
+ // against Control's 12 s helper timeout). The base call keeps its exact
50
+ // semantics; the learning/graph blocks are additive and drop silently when
51
+ // the second call is rejected, slow, or answers with an error (an older
52
+ // bridge prints an unknown-command error and exits 1, which rejects).
53
+ const [base, extra] = await Promise.allSettled([
54
+ callPython(['context-status'], 8_000),
55
+ callPython(['context-learning-graph-status'], 1_500),
56
+ ])
57
+ if (base.status === 'rejected') {
41
58
  res.json(normalizeContextBrowserStatus({
42
59
  available: false, protocol: 1, state: 'bridge_error',
43
60
  memory: { available: false, total: 0, state: 'bridge_error', reason: 'bridge_error' },
44
61
  threads: { available: false, total: 0, active: 0, stale: 0, resolved: 0, state: 'bridge_error', reason: 'bridge_error' },
45
62
  }))
63
+ return
64
+ }
65
+ const extraPayload = extra.status === 'fulfilled' && bridgePayload(extra.value) ? extra.value : null
66
+ const baseValue = bridgePayload(base.value) ? base.value : {}
67
+ res.json(normalizeContextBrowserStatus({
68
+ ...baseValue,
69
+ ...(extraPayload ? { learning: extraPayload.learning, graph: extraPayload.graph } : {}),
70
+ }))
71
+ })
72
+
73
+ /** True only for an object payload that is not an `{ error }` answer. */
74
+ /**
75
+ * A bridge answer that is a payload rather than a failure. An `error` KEY is not
76
+ * an error by itself: the graph-passages shape always carries one, `null` on
77
+ * success (graph_context._passages_shape), so only a non-null value counts.
78
+ */
79
+ function bridgePayload(value: unknown): value is Record<string, unknown> {
80
+ return typeof value === 'object' && value !== null && !Array.isArray(value) && bridgeErrorCode(value) === null
81
+ }
82
+
83
+ function bridgeErrorCode(value: unknown): string | null {
84
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || !('error' in value)) return null
85
+ const error = (value as { error: unknown }).error
86
+ if (error === null || error === undefined) return null
87
+ if (typeof error === 'string') return error
88
+ if (error && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'string') return (error as { code: string }).code
89
+ return 'bridge_error'
90
+ }
91
+
92
+ /**
93
+ * Map a learning/knowledge bridge answer to an HTTP status. Never a 200 for an
94
+ * `{ error }` payload: the file tier answers every learning command with the
95
+ * string-form `cos_pipeline_not_configured`, and an empty object would read as
96
+ * success.
97
+ */
98
+ function sendBridgeAnswer(res: import('express').Response, data: unknown, normalize: (value: unknown) => unknown): void {
99
+ const code = bridgeErrorCode(data)
100
+ if (code) {
101
+ const notFound = code.endsWith('_not_found')
102
+ const invalid = code.startsWith('invalid_')
103
+ res.status(notFound ? 404 : invalid ? 400 : 503).json({ error: code })
104
+ return
105
+ }
106
+ const value = normalize(data)
107
+ if (value === null || value === undefined) {
108
+ res.status(404).json({ error: 'not_found' })
109
+ return
110
+ }
111
+ res.json(value)
112
+ }
113
+
114
+ function noStore(res: import('express').Response): void {
115
+ res.set('Cache-Control', 'private, no-store')
116
+ }
117
+
118
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/
119
+
120
+ // ── Recent learning (6.44.5) ──
121
+
122
+ memoryRouter.get('/context/learning/status', async (_req, res) => {
123
+ noStore(res)
124
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
125
+ try {
126
+ sendBridgeAnswer(res, await callPython(['learning-status', '--no-memory'], 8_000), normalizeLearningStatus)
127
+ } catch {
128
+ res.status(503).json({ error: 'learning_unavailable' })
129
+ }
130
+ })
131
+
132
+ memoryRouter.get('/context/learning', async (req, res) => {
133
+ noStore(res)
134
+ const days = boundedInteger(req.query.days, 30, 1, 3650)
135
+ const limit = boundedInteger(req.query.limit, 50, 1, 50)
136
+ const kind = typeof req.query.kind === 'string' ? req.query.kind.replace(/[^a-z,]/g, '').slice(0, 160) : ''
137
+ const sinceTs = typeof req.query.since_ts === 'string' && Number.isFinite(Date.parse(req.query.since_ts)) ? req.query.since_ts.slice(0, 40) : ''
138
+ const sinceId = typeof req.query.since_event_id === 'string' && LEARNING_EVENT_ID_PATTERN.test(req.query.since_event_id) ? req.query.since_event_id : ''
139
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
140
+ const args = ['learning-events', '--days', String(days), '--limit', String(limit)]
141
+ if (kind) args.push('--kind', kind)
142
+ if (sinceTs) args.push('--since-ts', sinceTs)
143
+ if (sinceId) args.push('--since-event-id', sinceId)
144
+ try {
145
+ sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeLearningEvents(value, limit))
146
+ } catch {
147
+ res.status(503).json({ error: 'learning_unavailable' })
148
+ }
149
+ })
150
+
151
+ memoryRouter.get('/context/learning/:id', async (req, res) => {
152
+ noStore(res)
153
+ if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
154
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
155
+ try {
156
+ sendBridgeAnswer(res, await callPython(['learning-event', '--id', req.params.id], 8_000), normalizeLearningEventDetail)
157
+ } catch {
158
+ res.status(503).json({ error: 'learning_unavailable' })
159
+ }
160
+ })
161
+
162
+ // ── Knowledge graph (6.44.5) ──
163
+
164
+ memoryRouter.get('/context/graph/status', async (_req, res) => {
165
+ noStore(res)
166
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
167
+ try {
168
+ sendBridgeAnswer(res, await callPython(['graph-status'], 8_000), normalizeGraphStatus)
169
+ } catch {
170
+ res.status(503).json({ error: 'graph_unavailable' })
171
+ }
172
+ })
173
+
174
+ memoryRouter.get('/context/graph/search', async (req, res) => {
175
+ noStore(res)
176
+ const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
177
+ if (query.length < 2 || query.length > 160 || CONTROL_CHARACTER.test(query)) {
178
+ res.status(400).json({ error: 'q must be 2 to 160 characters', reason: 'invalid_query' })
179
+ return
180
+ }
181
+ const limit = boundedInteger(req.query.limit, 30, 1, 30)
182
+ const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
183
+ const type = typeof req.query.type === 'string' ? req.query.type.replace(/[^A-Za-z0-9_ -]/g, '').slice(0, 40) : ''
184
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
185
+ const args = ['graph-search', '--q', query, '--limit', String(limit), '--offset', String(offset)]
186
+ if (type) args.push('--type', type)
187
+ try {
188
+ sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeGraphSearch(value, limit))
189
+ } catch {
190
+ res.status(503).json({ error: 'graph_unavailable' })
191
+ }
192
+ })
193
+
194
+ memoryRouter.get('/context/graph/entity', async (req, res) => {
195
+ noStore(res)
196
+ const id = typeof req.query.id === 'string' ? req.query.id : ''
197
+ if (!id || id.length > GRAPH_ENTITY_ID_LIMIT || CONTROL_CHARACTER.test(id)) {
198
+ res.status(400).json({ error: 'invalid_entity_id' })
199
+ return
200
+ }
201
+ const limit = boundedInteger(req.query.limit, 30, 1, 30)
202
+ const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
203
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
204
+ try {
205
+ sendBridgeAnswer(res, await callPython(['graph-entity', '--id', id, '--offset', String(offset), '--limit', String(limit)], 8_000),
206
+ value => normalizeGraphEntity(value))
207
+ } catch {
208
+ res.status(503).json({ error: 'graph_unavailable' })
209
+ }
210
+ })
211
+
212
+ memoryRouter.get('/context/graph/passages', async (req, res) => {
213
+ noStore(res)
214
+ const entity = typeof req.query.entity === 'string' ? req.query.entity : ''
215
+ const relationA = typeof req.query.relationA === 'string' ? req.query.relationA : ''
216
+ const relationB = typeof req.query.relationB === 'string' ? req.query.relationB : ''
217
+ const bad = (value: string) => value.length > GRAPH_ENTITY_ID_LIMIT || CONTROL_CHARACTER.test(value)
218
+ if ((!entity && !(relationA && relationB)) || bad(entity) || bad(relationA) || bad(relationB) || (entity && (relationA || relationB))) {
219
+ res.status(400).json({ error: 'invalid_relation' })
220
+ return
221
+ }
222
+ const limit = boundedInteger(req.query.limit, 5, 1, 5)
223
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
224
+ const args = entity
225
+ ? ['graph-passages', '--entity', entity, '--limit', String(limit)]
226
+ : ['graph-passages', '--relation-a', relationA, '--relation-b', relationB, '--limit', String(limit)]
227
+ try {
228
+ sendBridgeAnswer(res, await callPython(args, 8_000), normalizeGraphPassages)
229
+ } catch {
230
+ res.status(503).json({ error: 'graph_unavailable' })
231
+ }
232
+ })
233
+
234
+ /**
235
+ * 202 Accepted: the bridge command only SPAWNS the detached build
236
+ * (start_new_session) and returns its receipt at once, so this handler never
237
+ * holds a lock, imports the SDK, or parses GraphML, and returns well inside the
238
+ * drain windows. Poll GET /context/graph/status for `build.state`.
239
+ */
240
+ memoryRouter.post('/context/graph/index', async (_req, res) => {
241
+ noStore(res)
242
+ if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
243
+ try {
244
+ const data = await callPython(['graph-index-build', '--reason', 'control'], 5_000)
245
+ const code = bridgeErrorCode(data)
246
+ if (code) { res.status(503).json({ error: code }); return }
247
+ res.status(202).json(normalizeIndexBuildKickoff(data))
248
+ } catch {
249
+ res.status(503).json({ error: 'graph_unavailable' })
46
250
  }
47
251
  })
48
252
 
@@ -19,6 +19,9 @@ import {
19
19
  taskDomains,
20
20
  tasksGate,
21
21
  workBadgeCount,
22
+ setTaskStage,
23
+ setTaskDoneWhen,
24
+ type TaskStage,
22
25
  } from '../lib/task-store.js'
23
26
 
24
27
  export const tasksRouter = Router()
@@ -191,8 +194,12 @@ tasksRouter.patch('/tasks/:id', async (req, res) => {
191
194
  await moveTask(domain, req.params.id, String(body.section))
192
195
  } else if ('checked' in body) {
193
196
  await checkTask({ domain, id: req.params.id, checked: body.checked === true })
197
+ } else if ('stage' in body) {
198
+ await setTaskStage(domain, req.params.id, String(body.stage) as TaskStage)
199
+ } else if ('doneWhen' in body) {
200
+ await setTaskDoneWhen(domain, req.params.id, body.doneWhen == null ? '' : String(body.doneWhen))
194
201
  } else {
195
- throw new TaskRunError(400, 'invalid_patch', 'Expected text, runAt, section, or checked.')
202
+ throw new TaskRunError(400, 'invalid_patch', 'Expected text, runAt, section, checked, stage, or doneWhen.')
196
203
  }
197
204
  res.json({ ok: true })
198
205
  } catch (error) {