@gotcos/glasses-server 6.44.4 → 6.44.6
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 +58 -0
- package/README.md +10 -0
- package/package.json +1 -1
- package/server/lib/cos-context-browser.ts +425 -0
- package/server/lib/python-bridge.ts +47 -1
- package/server/lib/task-dispatcher.ts +28 -3
- package/server/routes/memory.ts +242 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
## 6.44.6
|
|
2
|
+
|
|
3
|
+
What /qa found in 6.44.5 before anyone installed it.
|
|
4
|
+
|
|
5
|
+
- `GET /api/context/learning/review`: the strict To review set (the bridge's
|
|
6
|
+
`learning-to-review`, the same set the status block counts) as event rows,
|
|
7
|
+
one page of up to 200. 6.44.5's list approximated it with a kind filter and
|
|
8
|
+
showed "50 of 837" beside a chip that said 121.
|
|
9
|
+
- Every user value now rides as one argv token (`--q=...`, `--id=...`,
|
|
10
|
+
`--entity=...`), so a search term or entity name that begins with `-` can no
|
|
11
|
+
longer be read by the bridge's argument parser as a flag.
|
|
12
|
+
- An older COS bridge prints its unknown-command answer to stdout; the server
|
|
13
|
+
probed only stderr, so every learning route on such a bridge answered 503
|
|
14
|
+
instead of `cos_pipeline_not_configured`. Both are probed now.
|
|
15
|
+
- A `found: false` entity answer is a 404 with a class Control can name,
|
|
16
|
+
`index_missing` or `entity_not_found`, never a 503; the null-normalizer
|
|
17
|
+
fallback says `record_not_found`.
|
|
18
|
+
- `/api/context/status` answers `Cache-Control: private, no-store` like the
|
|
19
|
+
eight routes that shipped with it; it carries private counts.
|
|
20
|
+
- Every learning and graph route logs the bridge failure it used to swallow,
|
|
21
|
+
and the search shape passes the bridge's own `scope` through.
|
|
22
|
+
- On the COS side (same night): a JSON error exits 0 so it is parsed rather
|
|
23
|
+
than rejected, the bot-memory reach is bounded at 2.5 s so a wedged Qdrant
|
|
24
|
+
cannot blank the file stores, and entity descriptions cap at 12.
|
|
25
|
+
|
|
26
|
+
## 6.44.5
|
|
27
|
+
|
|
28
|
+
Recent learning and the knowledge graph, read-only, for COS Control.
|
|
29
|
+
|
|
30
|
+
- `GET /api/context/status` also carries `learning` and `graph` blocks. They come
|
|
31
|
+
from a second bridge call (`context-learning-graph-status`, 1.5 s budget) made
|
|
32
|
+
alongside the existing one with `Promise.allSettled`, so a bridge that predates
|
|
33
|
+
the command, times out, or answers `{ error }` leaves `memory` and `threads`
|
|
34
|
+
exactly as they were and the two blocks absent. Nothing an existing client
|
|
35
|
+
reads has changed; a client that wants the blocks checks for them.
|
|
36
|
+
- Seven read routes: `GET /api/context/learning` (cursor-paged events with a
|
|
37
|
+
per-store coverage map), `GET /api/context/learning/status`,
|
|
38
|
+
`GET /api/context/learning/:id`, `GET /api/context/graph/status`,
|
|
39
|
+
`GET /api/context/graph/search`, `GET /api/context/graph/entity`,
|
|
40
|
+
`GET /api/context/graph/passages`, and one `POST /api/context/graph/index`
|
|
41
|
+
that answers 202 and only asks the bridge to start a detached index build
|
|
42
|
+
(`graph-index-build --reason control`). The HTTP handler never runs
|
|
43
|
+
`--build-index`, `--apply-curation`, `--process-queue` or `graph-sync`; a
|
|
44
|
+
source test pins that. Every route sits behind the API token like the rest of
|
|
45
|
+
`/api`, answers `Cache-Control: private, no-store`, and 503s with the bridge
|
|
46
|
+
state when no pipeline is configured.
|
|
47
|
+
- Bodies are allowlisted before they leave: counts pass only as integers, event
|
|
48
|
+
ids must match `evt_` plus sixteen hex characters, unknown fields and local
|
|
49
|
+
paths never pass, and an `{ error }` from the bridge becomes a status (404 for
|
|
50
|
+
`*_not_found`, 400 for `invalid_*`, 503 otherwise) rather than a 200. Found
|
|
51
|
+
while writing the tests: the passages shape always carries an `error` key,
|
|
52
|
+
`null` on success, and the first draft read the key alone as a failure.
|
|
53
|
+
- Caps are the bridge's, restated at the edge: 50 events per page, 30 search
|
|
54
|
+
hits, 5 passages, a 200-character entity id, 160-character query.
|
|
55
|
+
- Older servers 404 these paths, which is how a client tells 6.44.5 apart from
|
|
56
|
+
what it had. A second Mac still on 6.44.2 picks up 6.44.3 and 6.44.4 with this
|
|
57
|
+
install; nothing in those two needs a step in between.
|
|
58
|
+
|
|
1
59
|
## 6.44.4
|
|
2
60
|
|
|
3
61
|
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 `cos_pipeline_not_configured` 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.6",
|
|
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,425 @@ 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
|
+
/** The strict To review set is small (121 today); one page shows it whole. */
|
|
476
|
+
export const LEARNING_REVIEW_LIMIT = 200
|
|
477
|
+
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
|
|
478
|
+
|
|
479
|
+
export interface LearningEvent {
|
|
480
|
+
event_id: string
|
|
481
|
+
lesson_id: string | null
|
|
482
|
+
event_type: string
|
|
483
|
+
ts: string
|
|
484
|
+
store: string
|
|
485
|
+
title: string
|
|
486
|
+
scope: string
|
|
487
|
+
category: string | null
|
|
488
|
+
engine: string
|
|
489
|
+
target: { kind: string | null; id: string; version: string | null }
|
|
490
|
+
applies_to: Array<{ kind: string; name: string; cadence: string | null; evidence: string }>
|
|
491
|
+
source_refs: Array<{ kind: string; id: string; excerpt: string }>
|
|
492
|
+
prior_event_id: string | null
|
|
493
|
+
outcome: { name: string; result: string; evaluator: string; ts: string | null } | null
|
|
494
|
+
provenance: string
|
|
495
|
+
ordinal?: number
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeLearningEvent(value: unknown, excerptLimit: number): LearningEvent | null {
|
|
499
|
+
const source = asRecord(value)
|
|
500
|
+
if (!source || typeof source.event_id !== 'string' || !LEARNING_EVENT_ID_PATTERN.test(source.event_id)) return null
|
|
501
|
+
const eventType = cleanContextText(source.event_type, 32)
|
|
502
|
+
if (!LEARNING_EVENT_TYPES.has(eventType)) return null
|
|
503
|
+
const ts = isoOrAbsent(source.ts)
|
|
504
|
+
if (!ts) return null
|
|
505
|
+
const target = asRecord(source.target) ?? {}
|
|
506
|
+
const outcome = asRecord(source.outcome)
|
|
507
|
+
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 => ({
|
|
508
|
+
kind: cleanContextText(r.kind, 16), name: cleanContextText(r.name, 160),
|
|
509
|
+
cadence: stringOrAbsent(r.cadence, 120) ?? null, evidence: cleanContextText(r.evidence, 240),
|
|
510
|
+
})) : []
|
|
511
|
+
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 => ({
|
|
512
|
+
kind: cleanContextText(r.kind, 32), id: cleanContextText(r.id, 200), excerpt: cleanContextText(r.excerpt, excerptLimit),
|
|
513
|
+
})) : []
|
|
514
|
+
const event: LearningEvent = {
|
|
515
|
+
event_id: source.event_id,
|
|
516
|
+
lesson_id: stringOrAbsent(source.lesson_id, 200) ?? null,
|
|
517
|
+
event_type: eventType,
|
|
518
|
+
ts,
|
|
519
|
+
store: cleanContextText(source.store, 40),
|
|
520
|
+
title: cleanContextText(source.title, 160),
|
|
521
|
+
scope: cleanContextText(source.scope, 24) || 'unknown',
|
|
522
|
+
category: stringOrAbsent(source.category, 160) ?? null,
|
|
523
|
+
engine: cleanContextText(source.engine, 24) || 'unknown',
|
|
524
|
+
target: { kind: stringOrAbsent(target.kind, 24) ?? null, id: cleanContextText(target.id, 200), version: stringOrAbsent(target.version, 64) ?? null },
|
|
525
|
+
applies_to: appliesTo,
|
|
526
|
+
source_refs: refs,
|
|
527
|
+
prior_event_id: typeof source.prior_event_id === 'string' && LEARNING_EVENT_ID_PATTERN.test(source.prior_event_id) ? source.prior_event_id : null,
|
|
528
|
+
outcome: outcome ? {
|
|
529
|
+
name: cleanContextText(outcome.name, 160), result: cleanContextText(outcome.result, 24),
|
|
530
|
+
evaluator: cleanContextText(outcome.evaluator, 64), ts: isoOrAbsent(outcome.ts) ?? null,
|
|
531
|
+
} : null,
|
|
532
|
+
provenance: cleanContextText(source.provenance, 24) || 'unavailable',
|
|
533
|
+
}
|
|
534
|
+
const ordinal = integerOrAbsent(source.ordinal)
|
|
535
|
+
if (ordinal !== undefined) event.ordinal = ordinal
|
|
536
|
+
return event
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export interface LearningCoverage { [store: string]: { state: string; count: number; detail?: string } }
|
|
540
|
+
|
|
541
|
+
export function normalizeLearningCoverage(value: unknown): LearningCoverage {
|
|
542
|
+
const source = asRecord(value) ?? {}
|
|
543
|
+
const out: LearningCoverage = {}
|
|
544
|
+
for (const [store, raw] of Object.entries(source).slice(0, 16)) {
|
|
545
|
+
const entry = asRecord(raw)
|
|
546
|
+
if (!entry) continue
|
|
547
|
+
const key = cleanContextText(store, 40)
|
|
548
|
+
if (!key) continue
|
|
549
|
+
out[key] = defined({
|
|
550
|
+
state: cleanContextText(entry.state, 32) || 'unavailable',
|
|
551
|
+
count: integerOrAbsent(entry.count) ?? 0,
|
|
552
|
+
detail: stringOrAbsent(entry.detail, 240),
|
|
553
|
+
}) as { state: string; count: number; detail?: string }
|
|
554
|
+
}
|
|
555
|
+
return out
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function normalizeLearningEvents(value: unknown, limit: number, max = LEARNING_LIST_LIMIT): {
|
|
559
|
+
events: LearningEvent[]; total: number; next_cursor: { since_ts: string; since_event_id: string } | null; coverage: LearningCoverage
|
|
560
|
+
} {
|
|
561
|
+
const source = asRecord(value) ?? {}
|
|
562
|
+
const cap = Math.max(1, Math.min(limit, max))
|
|
563
|
+
const events = (Array.isArray(source.events) ? source.events : []).slice(0, cap)
|
|
564
|
+
.map(item => normalizeLearningEvent(item, 240)).filter((e): e is LearningEvent => !!e)
|
|
565
|
+
const cursor = asRecord(source.next_cursor)
|
|
566
|
+
const sinceTs = cursor ? isoOrAbsent(cursor.since_ts) : undefined
|
|
567
|
+
const sinceId = cursor && typeof cursor.since_event_id === 'string' && LEARNING_EVENT_ID_PATTERN.test(cursor.since_event_id) ? cursor.since_event_id : undefined
|
|
568
|
+
return {
|
|
569
|
+
events,
|
|
570
|
+
total: integerOrAbsent(source.total) ?? events.length,
|
|
571
|
+
next_cursor: sinceTs && sinceId ? { since_ts: sinceTs, since_event_id: sinceId } : null,
|
|
572
|
+
coverage: normalizeLearningCoverage(source.coverage),
|
|
573
|
+
...(integerOrAbsent(source.review_count) !== undefined ? { review_count: integerOrAbsent(source.review_count) } : {}),
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export function normalizeLearningEventDetail(value: unknown): (LearningEvent & { detail: Record<string, unknown> }) | null {
|
|
578
|
+
const event = normalizeLearningEvent(value, 1200)
|
|
579
|
+
if (!event) return null
|
|
580
|
+
const raw = asRecord((value as Record<string, unknown>).detail) ?? {}
|
|
581
|
+
const detail: Record<string, unknown> = {}
|
|
582
|
+
for (const key of LEARNING_DETAIL_KEYS) {
|
|
583
|
+
const item = raw[key]
|
|
584
|
+
if (item === undefined || item === null) continue
|
|
585
|
+
if (typeof item === 'boolean') detail[key] = item
|
|
586
|
+
else if (Number.isInteger(item)) detail[key] = item
|
|
587
|
+
else if (typeof item === 'string') detail[key] = cleanContextText(item, 1200)
|
|
588
|
+
}
|
|
589
|
+
if (Array.isArray(raw.bodies)) detail.bodies = raw.bodies.slice(0, 12).map(body => cleanContextText(body, 1200))
|
|
590
|
+
return { ...event, detail }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export interface LearningStatus {
|
|
594
|
+
stores: LearningCoverage & { [store: string]: { state: string; count: number; detail?: string; readable?: boolean; last_ts?: string } }
|
|
595
|
+
to_review: { count: number; pattern: number; 'task-proposal': number }
|
|
596
|
+
orphan_decisions: number
|
|
597
|
+
no_store_active: boolean
|
|
598
|
+
engines: string[]
|
|
599
|
+
counts_by_type: Record<string, number>
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function normalizeLearningStatus(value: unknown): LearningStatus {
|
|
603
|
+
const source = asRecord(value) ?? {}
|
|
604
|
+
const stores: LearningStatus['stores'] = {}
|
|
605
|
+
for (const [store, raw] of Object.entries(asRecord(source.stores) ?? {}).slice(0, 16)) {
|
|
606
|
+
const entry = asRecord(raw)
|
|
607
|
+
const key = cleanContextText(store, 40)
|
|
608
|
+
if (!entry || !key) continue
|
|
609
|
+
stores[key] = defined({
|
|
610
|
+
state: cleanContextText(entry.state, 32) || 'unavailable',
|
|
611
|
+
count: integerOrAbsent(entry.count) ?? 0,
|
|
612
|
+
readable: booleanOrAbsent(entry.readable),
|
|
613
|
+
last_ts: isoOrAbsent(entry.last_ts),
|
|
614
|
+
}) as LearningStatus['stores'][string]
|
|
615
|
+
}
|
|
616
|
+
const review = asRecord(source.to_review) ?? {}
|
|
617
|
+
const counts: Record<string, number> = {}
|
|
618
|
+
for (const [type, raw] of Object.entries(asRecord(source.counts_by_type) ?? {})) {
|
|
619
|
+
if (LEARNING_EVENT_TYPES.has(type) && Number.isInteger(raw)) counts[type] = raw as number
|
|
620
|
+
}
|
|
621
|
+
return {
|
|
622
|
+
stores,
|
|
623
|
+
to_review: { count: integerOrAbsent(review.count) ?? 0, pattern: integerOrAbsent(review.pattern) ?? 0, 'task-proposal': integerOrAbsent(review['task-proposal']) ?? 0 },
|
|
624
|
+
orphan_decisions: integerOrAbsent(source.orphan_decisions) ?? 0,
|
|
625
|
+
no_store_active: source.no_store_active === true,
|
|
626
|
+
engines: stringList(source.engines, 8, 24),
|
|
627
|
+
counts_by_type: counts,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── Knowledge graph payloads (GET /context/graph/*) ──
|
|
632
|
+
|
|
633
|
+
export const GRAPH_ENTITY_ID_LIMIT = 200
|
|
634
|
+
export const GRAPH_INDEX_STATES = new Set(['fresh', 'stale', 'missing', 'source_missing'])
|
|
635
|
+
|
|
636
|
+
export function normalizeIndexReceipt(value: unknown): Record<string, unknown> | null {
|
|
637
|
+
const source = asRecord(value)
|
|
638
|
+
if (!source) return null
|
|
639
|
+
return defined({
|
|
640
|
+
state: stringOrAbsent(source.state, 16) ?? null,
|
|
641
|
+
started_at: isoOrAbsent(source.started_at) ?? null,
|
|
642
|
+
ended_at: isoOrAbsent(source.ended_at) ?? null,
|
|
643
|
+
pid: integerOrAbsent(source.pid) ?? null,
|
|
644
|
+
host: stringOrAbsent(source.host, 128) ?? null,
|
|
645
|
+
reason: stringOrAbsent(source.reason, 64) ?? null,
|
|
646
|
+
error: stringOrAbsent(source.error, 240) ?? null,
|
|
647
|
+
wall_s: typeof source.wall_s === 'number' && Number.isFinite(source.wall_s) ? source.wall_s : null,
|
|
648
|
+
peak_rss_mb: typeof source.peak_rss_mb === 'number' && Number.isFinite(source.peak_rss_mb) ? source.peak_rss_mb : null,
|
|
649
|
+
node_count: integerOrAbsent(source.node_count) ?? null,
|
|
650
|
+
edge_count: integerOrAbsent(source.edge_count) ?? null,
|
|
651
|
+
build_seq: integerOrAbsent(source.build_seq) ?? null,
|
|
652
|
+
degraded: source.degraded === true,
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function normalizeGraphStatus(value: unknown): Record<string, unknown> {
|
|
657
|
+
const source = asRecord(value) ?? {}
|
|
658
|
+
const src = asRecord(source.source) ?? {}
|
|
659
|
+
const queue = asRecord(source.queue) ?? {}
|
|
660
|
+
const budget = asRecord(source.budget) ?? {}
|
|
661
|
+
const lock = asRecord(source.lock) ?? {}
|
|
662
|
+
const processor = asRecord(source.processor) ?? {}
|
|
663
|
+
const indexState = stringOrAbsent(source.index_state, 32)
|
|
664
|
+
return {
|
|
665
|
+
entities: integerOrAbsent(source.entities) ?? null,
|
|
666
|
+
relationships: integerOrAbsent(source.relationships) ?? null,
|
|
667
|
+
source_updated_at: isoOrAbsent(source.source_updated_at) ?? null,
|
|
668
|
+
index_built_at: isoOrAbsent(source.index_built_at) ?? null,
|
|
669
|
+
index_state: indexState && GRAPH_INDEX_STATES.has(indexState) ? indexState : 'missing',
|
|
670
|
+
index_degraded: source.index_degraded === true,
|
|
671
|
+
build: normalizeIndexReceipt(source.build),
|
|
672
|
+
source: {
|
|
673
|
+
owner_host: stringOrAbsent(src.owner_host, 128) ?? null,
|
|
674
|
+
this_host: stringOrAbsent(src.this_host, 128) ?? null,
|
|
675
|
+
is_owner: src.is_owner === true,
|
|
676
|
+
owner_state: stringOrAbsent(src.owner_state, 16) ?? 'unset',
|
|
677
|
+
replica: src.replica === true,
|
|
678
|
+
source_sha256_prefix: stringOrAbsent(src.source_sha256_prefix, 16) ?? null,
|
|
679
|
+
index_built_on_host: stringOrAbsent(src.index_built_on_host, 128) ?? null,
|
|
680
|
+
index_host_mismatch: src.index_host_mismatch === true,
|
|
681
|
+
},
|
|
682
|
+
queue: {
|
|
683
|
+
live_total: integerOrAbsent(queue.live_total) ?? null,
|
|
684
|
+
pending: integerOrAbsent(queue.pending) ?? null,
|
|
685
|
+
failed: integerOrAbsent(queue.failed) ?? null,
|
|
686
|
+
deferred: integerOrAbsent(queue.deferred) ?? null,
|
|
687
|
+
oldest_pending_at: isoOrAbsent(queue.oldest_pending_at) ?? null,
|
|
688
|
+
oldest_pending_age_s: integerOrAbsent(queue.oldest_pending_age_s) ?? null,
|
|
689
|
+
missing_sources: integerOrAbsent(queue.missing_sources) ?? null,
|
|
690
|
+
conflict_copies: integerOrAbsent(queue.conflict_copies) ?? null,
|
|
691
|
+
},
|
|
692
|
+
budget: { used: integerOrAbsent(budget.used) ?? null, cap: integerOrAbsent(budget.cap) ?? null },
|
|
693
|
+
lock: { state: stringOrAbsent(lock.state, 16) ?? 'unknown', owner_pid: integerOrAbsent(lock.owner_pid) ?? null, error: stringOrAbsent(lock.error, 64) ?? null },
|
|
694
|
+
last_run: normalizeIndexReceipt(source.last_run),
|
|
695
|
+
processor: {
|
|
696
|
+
state: stringOrAbsent(processor.state, 24) ?? 'none',
|
|
697
|
+
plist: processor.plist === true,
|
|
698
|
+
cadence_s: integerOrAbsent(processor.cadence_s) ?? null,
|
|
699
|
+
last_run_at: isoOrAbsent(processor.last_run_at) ?? null,
|
|
700
|
+
last_outcome: stringOrAbsent(processor.last_outcome, 24) ?? null,
|
|
701
|
+
},
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export function normalizeGraphSearch(value: unknown, limit: number): Record<string, unknown> {
|
|
706
|
+
const source = asRecord(value) ?? {}
|
|
707
|
+
const items = (Array.isArray(source.items) ? source.items : []).slice(0, Math.max(1, Math.min(limit, 30))).map(asRecord)
|
|
708
|
+
.filter((r): r is Record<string, unknown> => !!r && typeof r.id === 'string')
|
|
709
|
+
.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) }))
|
|
710
|
+
return {
|
|
711
|
+
items,
|
|
712
|
+
total: integerOrAbsent(source.total) ?? items.length,
|
|
713
|
+
scope: stringOrAbsent(source.scope, 32) ?? 'full-index',
|
|
714
|
+
index_built_at: isoOrAbsent(source.index_built_at) ?? null,
|
|
715
|
+
index_state: stringOrAbsent(source.index_state, 32) ?? 'missing',
|
|
716
|
+
matcher: stringOrAbsent(source.matcher, 16) ?? null,
|
|
717
|
+
window: integerOrAbsent(source.window) ?? null,
|
|
718
|
+
offset: integerOrAbsent(source.offset) ?? 0,
|
|
719
|
+
limit: integerOrAbsent(source.limit) ?? items.length,
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export function normalizeGraphEntity(value: unknown): Record<string, unknown> | null {
|
|
724
|
+
const source = asRecord(value)
|
|
725
|
+
if (!source || source.found !== true || typeof source.id !== 'string') return null
|
|
726
|
+
const edges = (Array.isArray(source.edges) ? source.edges : []).slice(0, 30).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => ({
|
|
727
|
+
source: cleanContextText(r.source, GRAPH_ENTITY_ID_LIMIT), target: cleanContextText(r.target, GRAPH_ENTITY_ID_LIMIT),
|
|
728
|
+
weight: typeof r.weight === 'number' && Number.isFinite(r.weight) ? r.weight : null, description: cleanContextText(r.description, 240),
|
|
729
|
+
}))
|
|
730
|
+
const neighbors = (Array.isArray(source.neighbors) ? source.neighbors : []).slice(0, 30).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => ({
|
|
731
|
+
id: cleanContextText(r.id, GRAPH_ENTITY_ID_LIMIT), type: stringOrAbsent(r.type, 40) ?? null, degree: integerOrAbsent(r.degree) ?? 0,
|
|
732
|
+
}))
|
|
733
|
+
return {
|
|
734
|
+
found: true,
|
|
735
|
+
id: cleanContextText(source.id, GRAPH_ENTITY_ID_LIMIT),
|
|
736
|
+
type: stringOrAbsent(source.type, 40) ?? null,
|
|
737
|
+
degree: integerOrAbsent(source.degree) ?? 0,
|
|
738
|
+
description: cleanContextText(source.description, 1200),
|
|
739
|
+
description_length: integerOrAbsent(source.description_length) ?? null,
|
|
740
|
+
descriptions: stringList(source.descriptions, 12, 1200),
|
|
741
|
+
created_at: integerOrAbsent(source.created_at) ?? null,
|
|
742
|
+
first_seen_build: integerOrAbsent(source.first_seen_build) ?? null,
|
|
743
|
+
edges, neighbors,
|
|
744
|
+
total_relationships: integerOrAbsent(source.total_relationships) ?? edges.length,
|
|
745
|
+
offset: integerOrAbsent(source.offset) ?? 0,
|
|
746
|
+
limit: integerOrAbsent(source.limit) ?? edges.length,
|
|
747
|
+
source_status: stringOrAbsent(source.source_status, 16) ?? 'unresolved',
|
|
748
|
+
source_count: integerOrAbsent(source.source_count) ?? 0,
|
|
749
|
+
source_resolved: integerOrAbsent(source.source_resolved) ?? 0,
|
|
750
|
+
index_built_at: isoOrAbsent(source.index_built_at) ?? null,
|
|
751
|
+
index_state: stringOrAbsent(source.index_state, 32) ?? 'missing',
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export function normalizeGraphPassages(value: unknown): Record<string, unknown> {
|
|
756
|
+
const source = asRecord(value) ?? {}
|
|
757
|
+
const items = (Array.isArray(source.items) ? source.items : []).slice(0, 5).map(asRecord).filter((r): r is Record<string, unknown> => !!r).map(r => {
|
|
758
|
+
const src = asRecord(r.source) ?? {}
|
|
759
|
+
return {
|
|
760
|
+
chunk_id: cleanContextText(r.chunk_id, 80), doc_id: stringOrAbsent(r.doc_id, 80) ?? null,
|
|
761
|
+
order: integerOrAbsent(r.order) ?? null, excerpt: cleanContextText(r.excerpt, 1200),
|
|
762
|
+
source: {
|
|
763
|
+
status: stringOrAbsent(src.status, 16) ?? 'unresolved', key: stringOrAbsent(src.key, 200) ?? null,
|
|
764
|
+
title: stringOrAbsent(src.title, 200) ?? null, date: stringOrAbsent(src.date, 40) ?? null, summary: stringOrAbsent(src.summary, 120) ?? null,
|
|
765
|
+
},
|
|
766
|
+
}
|
|
767
|
+
})
|
|
768
|
+
return {
|
|
769
|
+
items,
|
|
770
|
+
total: integerOrAbsent(source.total) ?? items.length,
|
|
771
|
+
index_state: stringOrAbsent(source.index_state, 32) ?? null,
|
|
772
|
+
index_built_at: isoOrAbsent(source.index_built_at) ?? null,
|
|
773
|
+
fallback: stringOrAbsent(source.fallback, 24) ?? null,
|
|
774
|
+
unavailable: stringList(source.unavailable, 8, 64),
|
|
775
|
+
note: stringOrAbsent(source.note, 240) ?? null,
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function normalizeIndexBuildKickoff(value: unknown): { started: boolean; already_running: boolean; pid: number | null; receipt: Record<string, unknown> | null } {
|
|
780
|
+
const source = asRecord(value) ?? {}
|
|
781
|
+
return {
|
|
782
|
+
started: source.started === true,
|
|
783
|
+
already_running: source.already_running === true,
|
|
784
|
+
pid: integerOrAbsent(source.pid) ?? null,
|
|
785
|
+
receipt: normalizeIndexReceipt(source.receipt),
|
|
786
|
+
}
|
|
368
787
|
}
|
|
369
788
|
|
|
370
789
|
export function normalizeContextBrowserStatus(value: unknown): ContextBrowserStatus {
|
|
@@ -390,6 +809,10 @@ export function normalizeContextBrowserStatus(value: unknown): ContextBrowserSta
|
|
|
390
809
|
const incompatibleState = protocolCompatible ? '' : 'bridge_outdated'
|
|
391
810
|
const memoryState = incompatibleState || cleanState(memory.state, memoryAvailable ? 'ready' : 'unavailable')
|
|
392
811
|
const threadState = incompatibleState || cleanState(threads.state, threadsAvailable ? 'ready' : 'unavailable')
|
|
812
|
+
// The learning and graph blocks (6.44.5) sit behind the SAME protocol gate; an
|
|
813
|
+
// absent block stays absent so the two toEqual pins on older payloads hold.
|
|
814
|
+
const learning = protocolCompatible ? normalizeLearningBlock(source.learning) : null
|
|
815
|
+
const graph = protocolCompatible ? normalizeGraphBlock(source.graph) : null
|
|
393
816
|
return {
|
|
394
817
|
available: protocolCompatible && source.available === true,
|
|
395
818
|
protocol,
|
|
@@ -420,5 +843,7 @@ export function normalizeContextBrowserStatus(value: unknown): ContextBrowserSta
|
|
|
420
843
|
? { reason: 'bridge_outdated' }
|
|
421
844
|
: threads.reason ? { reason: cleanState(threads.reason, threadState) } : {}),
|
|
422
845
|
},
|
|
846
|
+
...(learning ? { learning } : {}),
|
|
847
|
+
...(graph ? { graph } : {}),
|
|
423
848
|
}
|
|
424
849
|
}
|
|
@@ -38,6 +38,26 @@ 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
|
+
'learning-to-review',
|
|
59
|
+
] as const
|
|
60
|
+
|
|
41
61
|
// The optional Python bridge is available only when the user points us at a real
|
|
42
62
|
// COS pipeline that ships the venv + bridge script. Standalone installs never
|
|
43
63
|
// have these, so callPython() degrades to a no-op.
|
|
@@ -186,6 +206,29 @@ function standaloneNoop(args: string[]): unknown {
|
|
|
186
206
|
return { error: 'cos_pipeline_not_configured' }
|
|
187
207
|
}
|
|
188
208
|
case 'badges': return {}
|
|
209
|
+
// Learning / knowledge commands (COS Control Memories, Phase 0.3). These
|
|
210
|
+
// need the Python bridge; the file tier has no learning stores or graph
|
|
211
|
+
// index to serve, so each says so in the STRING form the memory routes
|
|
212
|
+
// read. LEARNING_COMMANDS above is pinned against `_LEARNING_COMMANDS` in
|
|
213
|
+
// operations/scripts/cos_api_bridge.py by python-bridge-files.test.ts.
|
|
214
|
+
case 'context-learning-graph-status':
|
|
215
|
+
// The one learning command with a SHAPE: both blocks present and unavailable,
|
|
216
|
+
// so the /context/status merge path is exercised in the file tier too.
|
|
217
|
+
return {
|
|
218
|
+
learning: { available: false, state: 'cos_pipeline_not_configured' },
|
|
219
|
+
graph: { available: false, state: 'cos_pipeline_not_configured' },
|
|
220
|
+
protocol: 1,
|
|
221
|
+
}
|
|
222
|
+
case 'learning-events':
|
|
223
|
+
case 'learning-event':
|
|
224
|
+
case 'learning-status':
|
|
225
|
+
case 'graph-status':
|
|
226
|
+
case 'graph-search':
|
|
227
|
+
case 'graph-entity':
|
|
228
|
+
case 'graph-passages':
|
|
229
|
+
case 'graph-index-build':
|
|
230
|
+
case 'learning-to-review':
|
|
231
|
+
return { error: 'cos_pipeline_not_configured' }
|
|
189
232
|
case 'task-rows':
|
|
190
233
|
case 'task-capture':
|
|
191
234
|
case 'task-set-run-at':
|
|
@@ -214,7 +257,10 @@ function callPythonDirect(args: string[], timeoutMs: number, input?: string): Pr
|
|
|
214
257
|
{ cwd: COS_SCRIPTS_DIR!, timeout: timeoutMs, maxBuffer: 1024 * 1024 },
|
|
215
258
|
(err, stdout, stderr) => {
|
|
216
259
|
if (err) {
|
|
217
|
-
|
|
260
|
+
// The bridge prints its unknown-command answer to STDOUT and exits 1
|
|
261
|
+
// (cos_api_bridge.py), so an older checkout must be probed there too, or
|
|
262
|
+
// every learning route answers 503 instead of not-configured (QA 2026-09-06).
|
|
263
|
+
const msg = stderr?.trim() || stdout?.trim() || err.message
|
|
218
264
|
if (typeof msg === 'string' && msg.includes('unknown command')) {
|
|
219
265
|
return resolvePromise({ error: { code: 'cos_pipeline_not_configured', message: msg } })
|
|
220
266
|
}
|
|
@@ -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
|
@@ -13,8 +13,18 @@ import { searchMemories } from '../lib/context-library-search.js'
|
|
|
13
13
|
function contextConfigured(): boolean {
|
|
14
14
|
return contextSourceAvailable() !== null
|
|
15
15
|
}
|
|
16
|
-
import {
|
|
16
|
+
import { LEARNING_REVIEW_LIMIT,
|
|
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,
|
|
@@ -25,6 +35,7 @@ export const memoryRouter = Router()
|
|
|
25
35
|
let overviewCache: { expiresAt: number; value: ReturnType<typeof normalizeMemoryOverview> } | null = null
|
|
26
36
|
|
|
27
37
|
memoryRouter.get('/context/status', async (_req, res) => {
|
|
38
|
+
noStore(res)
|
|
28
39
|
if (!contextConfigured()) {
|
|
29
40
|
const state = pythonBridgeState()
|
|
30
41
|
res.json(normalizeContextBrowserStatus({
|
|
@@ -34,15 +45,241 @@ memoryRouter.get('/context/status', async (_req, res) => {
|
|
|
34
45
|
}))
|
|
35
46
|
return
|
|
36
47
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
// Both bridge calls are created in ONE synchronous statement so the second
|
|
49
|
+
// never extends the wall time past the first (8 s worst case, not 9.5 s,
|
|
50
|
+
// against Control's 12 s helper timeout). The base call keeps its exact
|
|
51
|
+
// semantics; the learning/graph blocks are additive and drop silently when
|
|
52
|
+
// the second call is rejected, slow, or answers with an error (an older
|
|
53
|
+
// bridge prints an unknown-command error and exits 1, which rejects).
|
|
54
|
+
const [base, extra] = await Promise.allSettled([
|
|
55
|
+
callPython(['context-status'], 8_000),
|
|
56
|
+
callPython(['context-learning-graph-status'], 1_500),
|
|
57
|
+
])
|
|
58
|
+
if (base.status === 'rejected') {
|
|
41
59
|
res.json(normalizeContextBrowserStatus({
|
|
42
60
|
available: false, protocol: 1, state: 'bridge_error',
|
|
43
61
|
memory: { available: false, total: 0, state: 'bridge_error', reason: 'bridge_error' },
|
|
44
62
|
threads: { available: false, total: 0, active: 0, stale: 0, resolved: 0, state: 'bridge_error', reason: 'bridge_error' },
|
|
45
63
|
}))
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const extraPayload = extra.status === 'fulfilled' && bridgePayload(extra.value) ? extra.value : null
|
|
67
|
+
const baseValue = bridgePayload(base.value) ? base.value : {}
|
|
68
|
+
res.json(normalizeContextBrowserStatus({
|
|
69
|
+
...baseValue,
|
|
70
|
+
...(extraPayload ? { learning: extraPayload.learning, graph: extraPayload.graph } : {}),
|
|
71
|
+
}))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/** True only for an object payload that is not an `{ error }` answer. */
|
|
75
|
+
/**
|
|
76
|
+
* A bridge answer that is a payload rather than a failure. An `error` KEY is not
|
|
77
|
+
* an error by itself: the graph-passages shape always carries one, `null` on
|
|
78
|
+
* success (graph_context._passages_shape), so only a non-null value counts.
|
|
79
|
+
*/
|
|
80
|
+
function bridgePayload(value: unknown): value is Record<string, unknown> {
|
|
81
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && bridgeErrorCode(value) === null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function bridgeErrorCode(value: unknown): string | null {
|
|
85
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || !('error' in value)) return null
|
|
86
|
+
const error = (value as { error: unknown }).error
|
|
87
|
+
if (error === null || error === undefined) return null
|
|
88
|
+
if (typeof error === 'string') return error
|
|
89
|
+
if (error && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'string') return (error as { code: string }).code
|
|
90
|
+
return 'bridge_error'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Map a learning/knowledge bridge answer to an HTTP status. Never a 200 for an
|
|
95
|
+
* `{ error }` payload: the file tier answers every learning command with the
|
|
96
|
+
* string-form `cos_pipeline_not_configured`, and an empty object would read as
|
|
97
|
+
* success.
|
|
98
|
+
*/
|
|
99
|
+
function sendBridgeAnswer(res: import('express').Response, data: unknown, normalize: (value: unknown) => unknown): void {
|
|
100
|
+
const code = bridgeErrorCode(data)
|
|
101
|
+
if (code) {
|
|
102
|
+
const notFound = code.endsWith('_not_found')
|
|
103
|
+
const invalid = code.startsWith('invalid_')
|
|
104
|
+
res.status(notFound ? 404 : invalid ? 400 : 503).json({ error: code })
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const value = normalize(data)
|
|
108
|
+
if (value === null || value === undefined) {
|
|
109
|
+
res.status(404).json({ error: 'record_not_found' })
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
res.json(value)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function noStore(res: import('express').Response): void {
|
|
116
|
+
res.set('Cache-Control', 'private, no-store')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/
|
|
120
|
+
|
|
121
|
+
// ── Recent learning (6.44.5) ──
|
|
122
|
+
|
|
123
|
+
memoryRouter.get('/context/learning/status', async (_req, res) => {
|
|
124
|
+
noStore(res)
|
|
125
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
126
|
+
try {
|
|
127
|
+
sendBridgeAnswer(res, await callPython(['learning-status', '--no-memory'], 8_000), normalizeLearningStatus)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.warn('[context] learning bridge failure:', (error as Error).message)
|
|
130
|
+
res.status(503).json({ error: 'learning_unavailable' })
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
memoryRouter.get('/context/learning', async (req, res) => {
|
|
135
|
+
noStore(res)
|
|
136
|
+
const days = boundedInteger(req.query.days, 30, 1, 3650)
|
|
137
|
+
const limit = boundedInteger(req.query.limit, 50, 1, 50)
|
|
138
|
+
const kind = typeof req.query.kind === 'string' ? req.query.kind.replace(/[^a-z,]/g, '').slice(0, 160) : ''
|
|
139
|
+
const sinceTs = typeof req.query.since_ts === 'string' && Number.isFinite(Date.parse(req.query.since_ts)) ? req.query.since_ts.slice(0, 40) : ''
|
|
140
|
+
const sinceId = typeof req.query.since_event_id === 'string' && LEARNING_EVENT_ID_PATTERN.test(req.query.since_event_id) ? req.query.since_event_id : ''
|
|
141
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
142
|
+
const args = ['learning-events', `--days=${days}`, `--limit=${limit}`]
|
|
143
|
+
if (kind) args.push(`--kind=${kind}`)
|
|
144
|
+
if (sinceTs) args.push(`--since-ts=${sinceTs}`)
|
|
145
|
+
if (sinceId) args.push(`--since-event-id=${sinceId}`)
|
|
146
|
+
try {
|
|
147
|
+
sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeLearningEvents(value, limit))
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.warn('[context] learning bridge failure:', (error as Error).message)
|
|
150
|
+
res.status(503).json({ error: 'learning_unavailable' })
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
memoryRouter.get('/context/learning/review', async (req, res) => {
|
|
155
|
+
noStore(res)
|
|
156
|
+
// The strict To review set (learning_events.to_review) as event rows, so the
|
|
157
|
+
// chip, Doctor and the list count the same thing. Small set: one page.
|
|
158
|
+
const limit = boundedInteger(req.query.limit, LEARNING_REVIEW_LIMIT, 1, LEARNING_REVIEW_LIMIT)
|
|
159
|
+
const days = req.query.days === undefined ? null : boundedInteger(req.query.days, 3650, 1, 3650)
|
|
160
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
161
|
+
try {
|
|
162
|
+
const args = ['learning-to-review', `--limit=${limit}`, ...(days ? [`--days=${days}`] : [])]
|
|
163
|
+
sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeLearningEvents(value, limit, LEARNING_REVIEW_LIMIT))
|
|
164
|
+
} catch (error) {
|
|
165
|
+
console.warn('[context] learning review bridge failure:', (error as Error).message)
|
|
166
|
+
res.status(503).json({ error: 'learning_unavailable' })
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
memoryRouter.get('/context/learning/:id', async (req, res) => {
|
|
171
|
+
noStore(res)
|
|
172
|
+
if (!LEARNING_EVENT_ID_PATTERN.test(req.params.id)) { res.status(400).json({ error: 'invalid_event_id' }); return }
|
|
173
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
174
|
+
try {
|
|
175
|
+
sendBridgeAnswer(res, await callPython(['learning-event', `--id=${req.params.id}`], 8_000), normalizeLearningEventDetail)
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.warn('[context] learning bridge failure:', (error as Error).message)
|
|
178
|
+
res.status(503).json({ error: 'learning_unavailable' })
|
|
179
|
+
}
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// ── Knowledge graph (6.44.5) ──
|
|
183
|
+
|
|
184
|
+
memoryRouter.get('/context/graph/status', async (_req, res) => {
|
|
185
|
+
noStore(res)
|
|
186
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
187
|
+
try {
|
|
188
|
+
sendBridgeAnswer(res, await callPython(['graph-status'], 8_000), normalizeGraphStatus)
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.warn('[context] graph bridge failure:', (error as Error).message)
|
|
191
|
+
res.status(503).json({ error: 'graph_unavailable' })
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
memoryRouter.get('/context/graph/search', async (req, res) => {
|
|
196
|
+
noStore(res)
|
|
197
|
+
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
|
198
|
+
if (query.length < 2 || query.length > 160 || CONTROL_CHARACTER.test(query)) {
|
|
199
|
+
res.status(400).json({ error: 'q must be 2 to 160 characters', reason: 'invalid_query' })
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
const limit = boundedInteger(req.query.limit, 30, 1, 30)
|
|
203
|
+
const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
|
|
204
|
+
const type = typeof req.query.type === 'string' ? req.query.type.replace(/[^A-Za-z0-9_ -]/g, '').slice(0, 40) : ''
|
|
205
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
206
|
+
// One token per value (`--q=...`): a value beginning with `-` is then never
|
|
207
|
+
// read by argparse as a flag (QA 2026-09-06).
|
|
208
|
+
const args = ['graph-search', `--q=${query}`, `--limit=${limit}`, `--offset=${offset}`]
|
|
209
|
+
if (type) args.push(`--type=${type}`)
|
|
210
|
+
try {
|
|
211
|
+
sendBridgeAnswer(res, await callPython(args, 8_000), value => normalizeGraphSearch(value, limit))
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.warn('[context] graph bridge failure:', (error as Error).message)
|
|
214
|
+
res.status(503).json({ error: 'graph_unavailable' })
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
memoryRouter.get('/context/graph/entity', async (req, res) => {
|
|
219
|
+
noStore(res)
|
|
220
|
+
const id = typeof req.query.id === 'string' ? req.query.id : ''
|
|
221
|
+
if (!id || id.length > GRAPH_ENTITY_ID_LIMIT || CONTROL_CHARACTER.test(id)) {
|
|
222
|
+
res.status(400).json({ error: 'invalid_entity_id' })
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
const limit = boundedInteger(req.query.limit, 30, 1, 30)
|
|
226
|
+
const offset = boundedInteger(req.query.offset, 0, 0, 100_000)
|
|
227
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
228
|
+
try {
|
|
229
|
+
const answer = await callPython(['graph-entity', `--id=${id}`, `--offset=${offset}`, `--limit=${limit}`], 8_000)
|
|
230
|
+
// A no-index or unknown-entity answer is a PAYLOAD from the bridge (found: false);
|
|
231
|
+
// it becomes a 404 with the class Control renders, never a 503 (QA 2026-09-06).
|
|
232
|
+
if (bridgePayload(answer) && answer.found !== true) {
|
|
233
|
+
res.status(404).json({ error: answer.index_state === 'missing' ? 'index_missing' : 'entity_not_found' })
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
sendBridgeAnswer(res, answer, value => normalizeGraphEntity(value))
|
|
237
|
+
} catch (error) {
|
|
238
|
+
console.warn('[context] graph bridge failure:', (error as Error).message)
|
|
239
|
+
res.status(503).json({ error: 'graph_unavailable' })
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
memoryRouter.get('/context/graph/passages', async (req, res) => {
|
|
244
|
+
noStore(res)
|
|
245
|
+
const entity = typeof req.query.entity === 'string' ? req.query.entity : ''
|
|
246
|
+
const relationA = typeof req.query.relationA === 'string' ? req.query.relationA : ''
|
|
247
|
+
const relationB = typeof req.query.relationB === 'string' ? req.query.relationB : ''
|
|
248
|
+
const bad = (value: string) => value.length > GRAPH_ENTITY_ID_LIMIT || CONTROL_CHARACTER.test(value)
|
|
249
|
+
if ((!entity && !(relationA && relationB)) || bad(entity) || bad(relationA) || bad(relationB) || (entity && (relationA || relationB))) {
|
|
250
|
+
res.status(400).json({ error: 'invalid_relation' })
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
const limit = boundedInteger(req.query.limit, 5, 1, 5)
|
|
254
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
255
|
+
const args = entity
|
|
256
|
+
? ['graph-passages', `--entity=${entity}`, `--limit=${limit}`]
|
|
257
|
+
: ['graph-passages', `--relation-a=${relationA}`, `--relation-b=${relationB}`, `--limit=${limit}`]
|
|
258
|
+
try {
|
|
259
|
+
sendBridgeAnswer(res, await callPython(args, 8_000), normalizeGraphPassages)
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.warn('[context] graph bridge failure:', (error as Error).message)
|
|
262
|
+
res.status(503).json({ error: 'graph_unavailable' })
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 202 Accepted: the bridge command only SPAWNS the detached build
|
|
268
|
+
* (start_new_session) and returns its receipt at once, so this handler never
|
|
269
|
+
* holds a lock, imports the SDK, or parses GraphML, and returns well inside the
|
|
270
|
+
* drain windows. Poll GET /context/graph/status for `build.state`.
|
|
271
|
+
*/
|
|
272
|
+
memoryRouter.post('/context/graph/index', async (_req, res) => {
|
|
273
|
+
noStore(res)
|
|
274
|
+
if (!contextConfigured()) { res.status(503).json({ error: pythonBridgeState() }); return }
|
|
275
|
+
try {
|
|
276
|
+
const data = await callPython(['graph-index-build', '--reason', 'control'], 5_000)
|
|
277
|
+
const code = bridgeErrorCode(data)
|
|
278
|
+
if (code) { res.status(503).json({ error: code }); return }
|
|
279
|
+
res.status(202).json(normalizeIndexBuildKickoff(data))
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.warn('[context] graph bridge failure:', (error as Error).message)
|
|
282
|
+
res.status(503).json({ error: 'graph_unavailable' })
|
|
46
283
|
}
|
|
47
284
|
})
|
|
48
285
|
|