@gotcos/glasses-server 6.44.4 → 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 +33 -0
- package/README.md +10 -0
- package/package.json +1 -1
- package/server/lib/cos-context-browser.ts +423 -0
- package/server/lib/python-bridge.ts +41 -0
- package/server/lib/task-dispatcher.ts +28 -3
- package/server/routes/memory.ts +208 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
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
|
+
|
|
1
34
|
## 6.44.4
|
|
2
35
|
|
|
3
36
|
Stage, a finish line, and no dispatch without one.
|
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
|
+
"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)
|
|
@@ -359,9 +383,10 @@ export async function runTaskNow(id: string, domain: string, injected?: TaskDisp
|
|
|
359
383
|
throw new TaskRunError(409, 'task_running', 'A run is already in flight for this task.')
|
|
360
384
|
}
|
|
361
385
|
// No finish line, no dispatch. An agent sent at a task with no definition of
|
|
362
|
-
// done cannot succeed at it and cannot be judged to have failed either
|
|
363
|
-
//
|
|
364
|
-
|
|
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)) {
|
|
365
390
|
throw new TaskRunError(
|
|
366
391
|
409,
|
|
367
392
|
'done_when_required',
|
package/server/routes/memory.ts
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|