@tpsdev-ai/flair 0.53.0 → 0.54.1
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/README.md +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1654 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +914 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1592 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/fabric-npm-install.js +87 -0
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +43 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +15 -7
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
- package/package.json +9 -4
- package/schemas/agent.graphql +15 -0
|
@@ -19,6 +19,9 @@ import { hybridEnabled } from "./bm25.js";
|
|
|
19
19
|
import { buildTrustBlock } from "./trust-block.js";
|
|
20
20
|
import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
|
|
21
21
|
import { estimateTokens } from "./token-estimate.js";
|
|
22
|
+
import { initialPosition, ORG_EVENT_STREAM } from "./agent-read-position-lib.js";
|
|
23
|
+
import { defaultReadPositionTable, ensureReadPosition } from "./agent-read-position.js";
|
|
24
|
+
import { catchupSeekTimestamp, isCatchupEligible } from "./org-event-catchup-lib.js";
|
|
22
25
|
/**
|
|
23
26
|
* POST /MemoryBootstrap
|
|
24
27
|
*
|
|
@@ -188,11 +191,11 @@ function buildTrustEntry(m, section) {
|
|
|
188
191
|
: undefined;
|
|
189
192
|
return { id: m.id, section, ...block, ...(matchQualityNote ? { matchQualityNote } : {}) };
|
|
190
193
|
}
|
|
191
|
-
// flair#1199/#1206 — the default cap on how many org events bootstrap
|
|
192
|
-
// Overridable per-request via `maxEvents`.
|
|
193
|
-
// #
|
|
194
|
-
//
|
|
195
|
-
//
|
|
194
|
+
// flair#1199/#1206 — the default DISPLAY cap on how many org events bootstrap
|
|
195
|
+
// ships. Overridable per-request via `maxEvents`. This is no longer a silent
|
|
196
|
+
// drop (flair#931): leftover events set eventsHasMore / eventsRemaining so the
|
|
197
|
+
// caller pages /OrgEventCatchup. Event slots are scarce AND (as of #1199)
|
|
198
|
+
// token-charged; the shared tokenBudget is the harder ceiling.
|
|
196
199
|
const MAX_ORG_EVENTS = 10;
|
|
197
200
|
// ─── Bootstrap scale fix (flair-bootstrap-scale-fix) tunables ───────────────
|
|
198
201
|
//
|
|
@@ -440,6 +443,12 @@ export class BootstrapMemories extends Resource {
|
|
|
440
443
|
// skipped events — never a gap derived from some larger tally, which
|
|
441
444
|
// could imply the existence of rows the caller was not allowed to see.
|
|
442
445
|
let eventsBudgetTruncated = 0;
|
|
446
|
+
// flair#931 — watermark path. Always present on the response so a
|
|
447
|
+
// display cap (maxEvents / budget) is never a silent drop: the agent
|
|
448
|
+
// pages /OrgEventCatchup until drained, then acks.
|
|
449
|
+
let eventWatermark = null;
|
|
450
|
+
let eventsHasMore = false;
|
|
451
|
+
let eventsRemaining = 0;
|
|
443
452
|
const leanMemory = (m, section) => ({
|
|
444
453
|
id: m.id,
|
|
445
454
|
content: m.content,
|
|
@@ -1293,28 +1302,27 @@ export class BootstrapMemories extends Resource {
|
|
|
1293
1302
|
// dependency — WorkspaceState/OrgEvent/Presence being briefly
|
|
1294
1303
|
// unavailable must not break bootstrap's core memory context.
|
|
1295
1304
|
}
|
|
1296
|
-
// --- 5.
|
|
1305
|
+
// --- 5. OrgEvents for this agent (per-agent watermark, flair#931) ---
|
|
1297
1306
|
try {
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1307
|
+
// Optional lastBootAt remains a createdAt floor (legacy / probe), never
|
|
1308
|
+
// the primary cursor. Default path is the durable watermark: "now" on
|
|
1309
|
+
// first seen (or FLAIR_CATCHUP_BACKFILL_MS), then position > watermark.
|
|
1310
|
+
// The old 24h window + silent slice(0, 10) is retired as the primary path.
|
|
1311
|
+
const sinceFloor = data?.lastBootAt ? new Date(data.lastBootAt).toISOString() : null;
|
|
1312
|
+
eventWatermark = await ensureReadPosition(defaultReadPositionTable(), agentId, ORG_EVENT_STREAM, initialPosition(), ctx);
|
|
1313
|
+
const seekTs = catchupSeekTimestamp(eventWatermark, sinceFloor);
|
|
1302
1314
|
const eventResults = [];
|
|
1303
|
-
// Seek the indexed
|
|
1315
|
+
// Seek the indexed floor before materializing events. Keep detail
|
|
1304
1316
|
// for no-op suppression/dedup even when the response omits it. No limit here:
|
|
1305
|
-
// expiry, targeting, dedup and budget admission must run before the cap.
|
|
1317
|
+
// expiry, targeting, dedup and budget admission must run before the display cap.
|
|
1306
1318
|
const recentEvents = withDetachedTxn(ctx, () => databases.flair.OrgEvent.search({
|
|
1307
|
-
|
|
1319
|
+
...(seekTs
|
|
1320
|
+
? { conditions: [{ attribute: "createdAt", comparator: "greater_than_equal", value: seekTs }] }
|
|
1321
|
+
: {}),
|
|
1308
1322
|
select: ["id", "kind", "summary", "detail", "targetIds", "createdAt", "expiresAt", "scope"],
|
|
1309
1323
|
}));
|
|
1310
1324
|
for await (const event of recentEvents) {
|
|
1311
|
-
if (!event
|
|
1312
|
-
continue;
|
|
1313
|
-
if (event.expiresAt && new Date(event.expiresAt) < new Date())
|
|
1314
|
-
continue;
|
|
1315
|
-
const targets = event.targetIds;
|
|
1316
|
-
const isRelevant = !targets || targets.length === 0 || targets.includes(agentId);
|
|
1317
|
-
if (!isRelevant)
|
|
1325
|
+
if (!isCatchupEligible(event, { participantId: agentId, after: eventWatermark, since: sinceFloor }))
|
|
1318
1326
|
continue;
|
|
1319
1327
|
// flair#1200 — suppress zero-row no-op auto-heal migration events at
|
|
1320
1328
|
// render. On a healthy store every boot emits a "migration graph-heal
|
|
@@ -1365,8 +1373,10 @@ export class BootstrapMemories extends Resource {
|
|
|
1365
1373
|
const dedupedEvents = [...eventBySignature.values()]
|
|
1366
1374
|
.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
|
|
1367
1375
|
for (const evt of dedupedEvents) {
|
|
1368
|
-
if (sections.events.length >= eventCap)
|
|
1369
|
-
|
|
1376
|
+
if (sections.events.length >= eventCap) {
|
|
1377
|
+
eventsRemaining++;
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1370
1380
|
// The structured object a connector actually reads (flair#1206). Lean by
|
|
1371
1381
|
// default; `detail` (the verbose migration-internals/summary-restating
|
|
1372
1382
|
// JSON) only when explicitly requested. Optional fields are omitted when
|
|
@@ -1389,6 +1399,7 @@ export class BootstrapMemories extends Resource {
|
|
|
1389
1399
|
// can say so instead of claiming "empty by design" (#1182 contract).
|
|
1390
1400
|
if (cost > tokenBudget) {
|
|
1391
1401
|
eventsBudgetTruncated++;
|
|
1402
|
+
eventsRemaining++;
|
|
1392
1403
|
continue;
|
|
1393
1404
|
}
|
|
1394
1405
|
const elapsed = Date.now() - new Date(evt.createdAt).getTime();
|
|
@@ -1400,9 +1411,10 @@ export class BootstrapMemories extends Resource {
|
|
|
1400
1411
|
includedEvents.push(structured);
|
|
1401
1412
|
tokenBudget -= cost;
|
|
1402
1413
|
}
|
|
1414
|
+
eventsHasMore = eventsRemaining > 0;
|
|
1403
1415
|
}
|
|
1404
1416
|
catch {
|
|
1405
|
-
// non-fatal: OrgEvent table may not exist yet
|
|
1417
|
+
// non-fatal: OrgEvent / AgentReadPosition table may not exist yet
|
|
1406
1418
|
}
|
|
1407
1419
|
// flair#1207 — derive the own-memory counters from the unique-id sets so the
|
|
1408
1420
|
// invariant memoriesIncluded + memoriesTruncated <= memoriesAvailable holds.
|
|
@@ -1615,8 +1627,9 @@ export class BootstrapMemories extends Resource {
|
|
|
1615
1627
|
const eventsHint = includedEvents.length === 0
|
|
1616
1628
|
? (eventsBudgetTruncated > 0
|
|
1617
1629
|
? `No org events fit the token budget: ${eventsBudgetTruncated} relevant event(s) cleared the `
|
|
1618
|
-
+ "
|
|
1619
|
-
|
|
1630
|
+
+ "watermark (or optional lastBootAt floor) but were budget-truncated. Raise maxTokens "
|
|
1631
|
+
+ "or GET /OrgEventCatchup to page the rest."
|
|
1632
|
+
: "No org events after your watermark were relevant to you (org-wide, or targeted at you) "
|
|
1620
1633
|
+ "after zero-row no-op auto-heal filtering. This container is present-but-empty by design, not dropped.")
|
|
1621
1634
|
: undefined;
|
|
1622
1635
|
// teammateFindings: [] — name WHICH legitimate empty this is (no task → no
|
|
@@ -1657,6 +1670,9 @@ export class BootstrapMemories extends Resource {
|
|
|
1657
1670
|
// delivered. Deduped (#1200) and targetIds-scoped (same set as the prose
|
|
1658
1671
|
// "## Recent Org Events" lines), so count/charge/delivery all agree.
|
|
1659
1672
|
events: includedEvents,
|
|
1673
|
+
eventWatermark,
|
|
1674
|
+
eventsHasMore,
|
|
1675
|
+
eventsRemaining,
|
|
1660
1676
|
...(currentTaskHint ? { currentTaskHint } : {}),
|
|
1661
1677
|
...(predictedHint ? { predictedHint } : {}),
|
|
1662
1678
|
// flair#1182 (0.44.11) — empty-container hints, present only when the
|
|
@@ -83,12 +83,11 @@ export class MemoryCandidate extends databases.flair.MemoryCandidate {
|
|
|
83
83
|
return memoryCandidateByIdReadGate.call(this, target, (t) => super.get(t));
|
|
84
84
|
}
|
|
85
85
|
/**
|
|
86
|
-
* Scope collection reads to the caller's own candidates.
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* "or"` in the request body cannot boolean-inject past the owner scope.
|
|
86
|
+
* Scope collection reads to the caller's own candidates. Agent-facing
|
|
87
|
+
* collection reads (`GET /MemoryCandidate`) reach this handler — the agentId
|
|
88
|
+
* condition is wrapped as the OUTERMOST `and` block (Relationship.ts's same
|
|
89
|
+
* "Security Critical" discipline) so a caller-supplied `operator: "or"` in
|
|
90
|
+
* the query cannot boolean-inject past the owner scope.
|
|
92
91
|
*/
|
|
93
92
|
async search(query) {
|
|
94
93
|
const ctx = this.getContext?.();
|
|
@@ -1,24 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OrgEventCatchup.ts —
|
|
2
|
+
* OrgEventCatchup.ts — Paginated catch-up against a per-agent watermark.
|
|
3
3
|
*
|
|
4
|
-
* GET
|
|
4
|
+
* GET /OrgEventCatchup/{participantId}?after=<position>&since=<ISO>&limit=50
|
|
5
|
+
* POST /OrgEventCatchup/{participantId} { position }
|
|
5
6
|
*
|
|
6
7
|
* Returns events where:
|
|
7
8
|
* - targetIds includes participantId OR targetIds is empty/null
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* - position > after (after defaults to the durable per-agent watermark)
|
|
10
|
+
* - optional `since` is an extra createdAt floor, never the primary cursor
|
|
11
|
+
*
|
|
12
|
+
* Ordered by position ascending. Page-sized, never silently truncated
|
|
13
|
+
* (`hasMore` + `nextAfter` — the caller pages until drained). Watermark
|
|
14
|
+
* advances on ack (POST), not on read — at-least-once; consumers must be
|
|
15
|
+
* idempotent (flair#931).
|
|
11
16
|
*/
|
|
12
17
|
import { Resource, databases } from "harper";
|
|
13
18
|
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
19
|
+
import { initialPosition, ORG_EVENT_STREAM, parsePageSize, positionAfterTimestamp, } from "./agent-read-position-lib.js";
|
|
20
|
+
import { advanceReadPosition, defaultReadPositionTable, ensureReadPosition, getReadPosition, } from "./agent-read-position.js";
|
|
21
|
+
import { catchupSeekTimestamp, collectCatchupEvents, pageCatchupEvents, } from "./org-event-catchup-lib.js";
|
|
22
|
+
function queryValue(pathInfo, name) {
|
|
23
|
+
if (typeof pathInfo !== "object" || pathInfo === null)
|
|
24
|
+
return null;
|
|
25
|
+
return pathInfo.conditions?.find((c) => c.attribute === name)?.value ?? null;
|
|
26
|
+
}
|
|
27
|
+
function pathParticipantId(pathInfo, resource) {
|
|
28
|
+
return ((typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
|
|
29
|
+
(typeof pathInfo === "string" ? pathInfo : null) ??
|
|
30
|
+
resource.getId?.() ??
|
|
31
|
+
null);
|
|
32
|
+
}
|
|
33
|
+
function ownerDenied(auth, participantId) {
|
|
34
|
+
if (auth.kind === "anonymous")
|
|
35
|
+
return true;
|
|
36
|
+
if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== participantId)
|
|
37
|
+
return true;
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function forbidden() {
|
|
41
|
+
return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
42
|
+
}
|
|
43
|
+
async function resolveAfter(participantId, pathInfo, ctx) {
|
|
44
|
+
const afterParam = queryValue(pathInfo, "after");
|
|
45
|
+
const since = queryValue(pathInfo, "since");
|
|
46
|
+
if (since) {
|
|
47
|
+
const sinceDate = new Date(since);
|
|
48
|
+
if (isNaN(sinceDate.getTime())) {
|
|
49
|
+
throw new Response(JSON.stringify({ error: "invalid since timestamp" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const table = defaultReadPositionTable();
|
|
53
|
+
const watermark = await getReadPosition(table, participantId, ORG_EVENT_STREAM, ctx);
|
|
54
|
+
if (afterParam != null && afterParam !== "") {
|
|
55
|
+
return { after: afterParam, watermark, since };
|
|
56
|
+
}
|
|
57
|
+
if (watermark) {
|
|
58
|
+
return { after: watermark, watermark, since };
|
|
59
|
+
}
|
|
60
|
+
if (since) {
|
|
61
|
+
// Legacy explicit since, no watermark yet — do not invent a "now" floor
|
|
62
|
+
// that would hide the window the caller asked for.
|
|
63
|
+
return { after: positionAfterTimestamp(since), watermark: null, since };
|
|
64
|
+
}
|
|
65
|
+
const created = await ensureReadPosition(table, participantId, ORG_EVENT_STREAM, initialPosition(), ctx);
|
|
66
|
+
return { after: created, watermark: created, since: null };
|
|
67
|
+
}
|
|
68
|
+
async function searchOrgEvents(seekTs) {
|
|
69
|
+
const table = databases.flair?.OrgEvent;
|
|
70
|
+
if (!table?.search) {
|
|
71
|
+
async function* empty() { }
|
|
72
|
+
return empty();
|
|
73
|
+
}
|
|
74
|
+
const query = seekTs
|
|
75
|
+
? {
|
|
76
|
+
conditions: [{ attribute: "createdAt", comparator: "greater_than_equal", value: seekTs }],
|
|
77
|
+
}
|
|
78
|
+
: undefined;
|
|
79
|
+
return table.search(query);
|
|
80
|
+
}
|
|
14
81
|
export class OrgEventCatchup extends Resource {
|
|
15
82
|
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
16
83
|
// admin elevation). Any verified agent may catch up; participant scoping is in
|
|
17
|
-
// get(). Uses getContext().request — the reliable v5 path (this.request
|
|
18
|
-
// populated on Harper v5 Resources).
|
|
84
|
+
// get()/post(). Uses getContext().request — the reliable v5 path (this.request
|
|
85
|
+
// is not populated on Harper v5 Resources).
|
|
19
86
|
async allowRead() {
|
|
20
87
|
return allowVerified(this.getContext?.());
|
|
21
88
|
}
|
|
89
|
+
async allowCreate() {
|
|
90
|
+
return allowVerified(this.getContext?.());
|
|
91
|
+
}
|
|
22
92
|
// HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
|
|
23
93
|
async get(pathInfo) {
|
|
24
94
|
// Harper v5 does not populate this.request on Resource subclasses —
|
|
@@ -27,55 +97,64 @@ export class OrgEventCatchup extends Resource {
|
|
|
27
97
|
// any).request` read was always undefined, so the ownership check below
|
|
28
98
|
// never ran — fail-open cross-agent read).
|
|
29
99
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
30
|
-
|
|
31
|
-
const participantId = (
|
|
32
|
-
(typeof pathInfo === "string" ? pathInfo : null) ??
|
|
33
|
-
this.getId?.() ??
|
|
34
|
-
null;
|
|
100
|
+
const ctx = this.getContext?.();
|
|
101
|
+
const participantId = pathParticipantId(pathInfo, this);
|
|
35
102
|
if (!participantId) {
|
|
36
103
|
return new Response(JSON.stringify({ error: "participantId required in path: GET /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
37
104
|
}
|
|
38
105
|
// Auth: internal calls and admins pass unfiltered; a verified agent may only
|
|
39
106
|
// fetch its own catchup feed; anonymous is denied. allowRead() already
|
|
40
107
|
// blocks anonymous HTTP, but this handler must fail closed on its own too.
|
|
41
|
-
if (auth
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
108
|
+
if (ownerDenied(auth, participantId))
|
|
109
|
+
return forbidden();
|
|
110
|
+
let resolved;
|
|
111
|
+
try {
|
|
112
|
+
resolved = await resolveAfter(participantId, pathInfo, ctx);
|
|
46
113
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
? pathInfo.conditions?.find((c) => c.attribute === "since")?.value ?? null
|
|
52
|
-
: null);
|
|
53
|
-
if (!since) {
|
|
54
|
-
return new Response(JSON.stringify({ error: "since query parameter required (ISO timestamp)" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
114
|
+
catch (err) {
|
|
115
|
+
if (err instanceof Response)
|
|
116
|
+
return err;
|
|
117
|
+
throw err;
|
|
55
118
|
}
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
119
|
+
const pageSize = parsePageSize(queryValue(pathInfo, "limit"));
|
|
120
|
+
const seekTs = catchupSeekTimestamp(resolved.after, resolved.since);
|
|
121
|
+
const ordered = await collectCatchupEvents(await searchOrgEvents(seekTs), {
|
|
122
|
+
participantId,
|
|
123
|
+
after: resolved.after,
|
|
124
|
+
since: resolved.since,
|
|
125
|
+
});
|
|
126
|
+
const paged = pageCatchupEvents(ordered, resolved.after, pageSize);
|
|
127
|
+
return {
|
|
128
|
+
events: paged.page,
|
|
129
|
+
watermark: resolved.watermark,
|
|
130
|
+
after: resolved.after,
|
|
131
|
+
nextAfter: paged.nextAfter,
|
|
132
|
+
hasMore: paged.hasMore,
|
|
133
|
+
pageSize,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Advance-on-ack. Body: `{ position }` (the last delivered event's
|
|
138
|
+
* `position`, or `nextAfter` from a drained page). Monotonic.
|
|
139
|
+
*/
|
|
140
|
+
async post(content, pathInfo) {
|
|
141
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
142
|
+
const ctx = this.getContext?.();
|
|
143
|
+
const participantId = pathParticipantId(pathInfo, this) ??
|
|
144
|
+
(content && typeof content === "object" ? content.participantId ?? content.agentId : null) ??
|
|
145
|
+
null;
|
|
146
|
+
if (!participantId) {
|
|
147
|
+
return new Response(JSON.stringify({ error: "participantId required in path: POST /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
59
148
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const targets = event.targetIds;
|
|
68
|
-
const isTargeted = !targets || targets.length === 0 || targets.includes(participantId);
|
|
69
|
-
if (!isTargeted)
|
|
70
|
-
continue;
|
|
71
|
-
// Skip expired events
|
|
72
|
-
if (event.expiresAt && new Date(event.expiresAt) < new Date())
|
|
73
|
-
continue;
|
|
74
|
-
results.push(event);
|
|
149
|
+
if (ownerDenied(auth, participantId))
|
|
150
|
+
return forbidden();
|
|
151
|
+
const position = content && typeof content === "object"
|
|
152
|
+
? String(content.position ?? content.ackThrough ?? "")
|
|
153
|
+
: "";
|
|
154
|
+
if (!position) {
|
|
155
|
+
return new Response(JSON.stringify({ error: "position required to ack catch-up (advance-on-ack)" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
75
156
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// Limit to 50
|
|
79
|
-
return results.slice(0, 50);
|
|
157
|
+
const result = await advanceReadPosition(defaultReadPositionTable(), participantId, ORG_EVENT_STREAM, position, ctx);
|
|
158
|
+
return { participantId, stream: ORG_EVENT_STREAM, ...result };
|
|
80
159
|
}
|
|
81
160
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-read-position-lib.ts — pure per-agent read-position (watermark) helpers.
|
|
3
|
+
*
|
|
4
|
+
* This is the reusable catch-up floor (flair#931), not OrgEvent-table plumbing.
|
|
5
|
+
* OrgEventCatchup is the first consumer; light-comms (#1583) inherits the same
|
|
6
|
+
* (agentId, stream) → position primitive when the board lands.
|
|
7
|
+
*
|
|
8
|
+
* Position is a monotonic total order, NOT a wall-clock `since`:
|
|
9
|
+
* `${createdAt}\t${id}`
|
|
10
|
+
* ISO-8601 timestamps sort lexicographically; `id` breaks same-ms ties so a
|
|
11
|
+
* sort without a tie-break cannot drop an event. Compare with `<` / `>`.
|
|
12
|
+
*
|
|
13
|
+
* Harper-free so unit tests can pin encoding, paging, and init without a store.
|
|
14
|
+
*/
|
|
15
|
+
import { compareKey } from "./sort-comparators.js";
|
|
16
|
+
/** First shipped stream. Additional streams (message-board, …) are additive. */
|
|
17
|
+
export const ORG_EVENT_STREAM = "org-event";
|
|
18
|
+
export const POSITION_SEP = "\t";
|
|
19
|
+
export const DEFAULT_CATCHUP_PAGE_SIZE = 50;
|
|
20
|
+
export const MAX_CATCHUP_PAGE_SIZE = 500;
|
|
21
|
+
/** Env: bounded backfill on first watermark. Unset = 24h (retired window, once). `0` = "now". */
|
|
22
|
+
export const CATCHUP_BACKFILL_MS_ENV = "FLAIR_CATCHUP_BACKFILL_MS";
|
|
23
|
+
export const DEFAULT_CATCHUP_BACKFILL_MS = 24 * 3600_000;
|
|
24
|
+
export function readPositionId(agentId, stream) {
|
|
25
|
+
return `${agentId}:${stream}`;
|
|
26
|
+
}
|
|
27
|
+
/** Total-order position for a record that has `createdAt` + `id`. */
|
|
28
|
+
export function recordPosition(record) {
|
|
29
|
+
return `${record.createdAt ?? ""}${POSITION_SEP}${record.id ?? ""}`;
|
|
30
|
+
}
|
|
31
|
+
/** Exclusive cursor just after every record at-or-before this ISO timestamp. */
|
|
32
|
+
export function positionAfterTimestamp(iso) {
|
|
33
|
+
return `${iso}${POSITION_SEP}`;
|
|
34
|
+
}
|
|
35
|
+
export function comparePosition(a, b) {
|
|
36
|
+
return compareKey(a, b);
|
|
37
|
+
}
|
|
38
|
+
/** Leading createdAt of a position, or null if the cursor has no timestamp. */
|
|
39
|
+
export function createdAtFloorFromPosition(position) {
|
|
40
|
+
if (!position)
|
|
41
|
+
return null;
|
|
42
|
+
const sep = position.indexOf(POSITION_SEP);
|
|
43
|
+
const ts = sep === -1 ? position : position.slice(0, sep);
|
|
44
|
+
return ts || null;
|
|
45
|
+
}
|
|
46
|
+
export function laterTimestamp(a, b) {
|
|
47
|
+
if (!a)
|
|
48
|
+
return b || null;
|
|
49
|
+
if (!b)
|
|
50
|
+
return a;
|
|
51
|
+
return a >= b ? a : b;
|
|
52
|
+
}
|
|
53
|
+
export function parseBackfillMs(raw = process.env[CATCHUP_BACKFILL_MS_ENV]) {
|
|
54
|
+
if (raw === undefined || raw === "")
|
|
55
|
+
return DEFAULT_CATCHUP_BACKFILL_MS;
|
|
56
|
+
const n = Number(raw);
|
|
57
|
+
if (!Number.isFinite(n) || n < 0)
|
|
58
|
+
return DEFAULT_CATCHUP_BACKFILL_MS;
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Sane first watermark. Default a 24h bounded backfill (the retired bootstrap
|
|
63
|
+
* window, applied once); `FLAIR_CATCHUP_BACKFILL_MS=0` is Flint's "now"
|
|
64
|
+
* (fresh agent does not replay history). After init the durable position governs.
|
|
65
|
+
*/
|
|
66
|
+
export function initialPosition(nowMs = Date.now(), backfillMs = parseBackfillMs()) {
|
|
67
|
+
const floor = Math.max(0, nowMs - Math.max(0, backfillMs));
|
|
68
|
+
return positionAfterTimestamp(new Date(floor).toISOString());
|
|
69
|
+
}
|
|
70
|
+
export function parsePageSize(raw, fallback = DEFAULT_CATCHUP_PAGE_SIZE, max = MAX_CATCHUP_PAGE_SIZE) {
|
|
71
|
+
const n = typeof raw === "number" ? raw : raw != null && raw !== "" ? Number(raw) : fallback;
|
|
72
|
+
if (!Number.isFinite(n))
|
|
73
|
+
return fallback;
|
|
74
|
+
return Math.min(max, Math.max(1, Math.floor(n)));
|
|
75
|
+
}
|
|
76
|
+
/** Exclusive `after`, already-sorted by position ascending. Never silent-truncates. */
|
|
77
|
+
export function pageAfter(ordered, after, pageSize) {
|
|
78
|
+
const slice = ordered.filter((row) => comparePosition(row.position, after) > 0);
|
|
79
|
+
const hasMore = slice.length > pageSize;
|
|
80
|
+
const page = hasMore ? slice.slice(0, pageSize) : slice;
|
|
81
|
+
const nextAfter = page.length > 0 ? page[page.length - 1].position : after;
|
|
82
|
+
return { page, hasMore, nextAfter };
|
|
83
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-read-position.ts — durable per-agent read-position (watermark) store.
|
|
3
|
+
*
|
|
4
|
+
* Storage + monotonic advance for resources/agent-read-position-lib.ts.
|
|
5
|
+
* Owner-scoping lives in the HTTP resource / catch-up handler; this module
|
|
6
|
+
* is the internal contract for raw table access (Harper table I/O bypasses
|
|
7
|
+
* Resource rules).
|
|
8
|
+
*
|
|
9
|
+
* Advance is at-least-once (advance-on-ack): only move forward, never back.
|
|
10
|
+
* Same-process concurrent advances serialize on a per-id promise tail so two
|
|
11
|
+
* acks cannot clobber a higher watermark with a lower one.
|
|
12
|
+
*/
|
|
13
|
+
import { databases } from "harper";
|
|
14
|
+
import { withDetachedTxn } from "./table-helpers.js";
|
|
15
|
+
import { comparePosition, readPositionId } from "./agent-read-position-lib.js";
|
|
16
|
+
export function defaultReadPositionTable() {
|
|
17
|
+
try {
|
|
18
|
+
const table = databases.flair?.AgentReadPosition;
|
|
19
|
+
if (!table || typeof table.get !== "function" || typeof table.put !== "function")
|
|
20
|
+
return null;
|
|
21
|
+
return table;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const tails = new Map();
|
|
28
|
+
function enqueue(id, work) {
|
|
29
|
+
const prev = tails.get(id) ?? Promise.resolve();
|
|
30
|
+
const next = prev.then(work, work);
|
|
31
|
+
tails.set(id, next);
|
|
32
|
+
return next.finally(() => {
|
|
33
|
+
if (tails.get(id) === next)
|
|
34
|
+
tails.delete(id);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function readRow(table, id, ctx) {
|
|
38
|
+
try {
|
|
39
|
+
const row = await withDetachedTxn(ctx, () => table.get(id));
|
|
40
|
+
return row && typeof row.position === "string" ? row : null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function getReadPosition(table, agentId, stream, ctx) {
|
|
47
|
+
if (!table)
|
|
48
|
+
return null;
|
|
49
|
+
const row = await readRow(table, readPositionId(agentId, stream), ctx);
|
|
50
|
+
return row?.position ?? null;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Return the stored watermark, creating it at `initial` when absent.
|
|
54
|
+
* Concurrent first-writers serialize; the stored row wins if one appears.
|
|
55
|
+
*/
|
|
56
|
+
export async function ensureReadPosition(table, agentId, stream, initial, ctx, now = () => new Date().toISOString()) {
|
|
57
|
+
if (!table)
|
|
58
|
+
return initial;
|
|
59
|
+
const id = readPositionId(agentId, stream);
|
|
60
|
+
return enqueue(id, async () => {
|
|
61
|
+
const existing = await readRow(table, id, ctx);
|
|
62
|
+
if (existing)
|
|
63
|
+
return existing.position;
|
|
64
|
+
const row = {
|
|
65
|
+
id,
|
|
66
|
+
agentId,
|
|
67
|
+
stream,
|
|
68
|
+
position: initial,
|
|
69
|
+
updatedAt: now(),
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
await withDetachedTxn(ctx, () => table.put(row));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
const raced = await readRow(table, id, ctx);
|
|
76
|
+
if (raced)
|
|
77
|
+
return raced.position;
|
|
78
|
+
return initial;
|
|
79
|
+
}
|
|
80
|
+
return initial;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Monotonic advance. A lower-or-equal ack is a no-op. Serialized per
|
|
85
|
+
* (agent, stream) so concurrent catch-up acks cannot regress the watermark.
|
|
86
|
+
*/
|
|
87
|
+
export async function advanceReadPosition(table, agentId, stream, position, ctx, now = () => new Date().toISOString()) {
|
|
88
|
+
if (!position) {
|
|
89
|
+
const current = table ? await getReadPosition(table, agentId, stream, ctx) : null;
|
|
90
|
+
return { position: current ?? "", advanced: false };
|
|
91
|
+
}
|
|
92
|
+
if (!table)
|
|
93
|
+
return { position, advanced: false };
|
|
94
|
+
const id = readPositionId(agentId, stream);
|
|
95
|
+
return enqueue(id, async () => {
|
|
96
|
+
const existing = await readRow(table, id, ctx);
|
|
97
|
+
const current = existing?.position ?? "";
|
|
98
|
+
if (current && comparePosition(position, current) <= 0) {
|
|
99
|
+
return { position: current, advanced: false };
|
|
100
|
+
}
|
|
101
|
+
const row = {
|
|
102
|
+
id,
|
|
103
|
+
agentId,
|
|
104
|
+
stream,
|
|
105
|
+
position,
|
|
106
|
+
updatedAt: now(),
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
await withDetachedTxn(ctx, () => table.put(row));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
const raced = await readRow(table, id, ctx);
|
|
113
|
+
if (raced && comparePosition(raced.position, position) >= 0) {
|
|
114
|
+
return { position: raced.position, advanced: false };
|
|
115
|
+
}
|
|
116
|
+
return { position: current || position, advanced: false };
|
|
117
|
+
}
|
|
118
|
+
return { position, advanced: true };
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -175,6 +175,23 @@ export function resolveEmbedThreads(env = process.env, cores = availableParallel
|
|
|
175
175
|
const safeCores = Number.isFinite(cores) && cores >= 1 ? Math.floor(cores) : 1;
|
|
176
176
|
return Math.max(1, safeCores - 1);
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* llama.cpp GPU-layer offload passed to HFE `register({config:{gpuLayers}})`.
|
|
180
|
+
*
|
|
181
|
+
* DEFAULT IS UNCHANGED. Unset / empty / non-integer / negative → `undefined`,
|
|
182
|
+
* and the register() call OMITS the field so HFE keeps its own default of 0
|
|
183
|
+
* (CPU only). This is a measurement pin for the ingest-throughput bench
|
|
184
|
+
* (flair#1436 / #1437), not a product default change. #1437 is the decision
|
|
185
|
+
* about whether to detect-and-default gpuLayers; this function must not
|
|
186
|
+
* pre-empt it.
|
|
187
|
+
*
|
|
188
|
+
* Override: `FLAIR_EMBED_GPU_LAYERS` — a non-negative integer, env-only
|
|
189
|
+
* (same persist-path reason as `FLAIR_EMBED_THREADS`). 0 = CPU only; 99 =
|
|
190
|
+
* full offload (the Metal cell). Invalid values fall through to omit.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveEmbedGpuLayers(env = process.env) {
|
|
193
|
+
return parseNonNegativeInt(env.FLAIR_EMBED_GPU_LAYERS);
|
|
194
|
+
}
|
|
178
195
|
function parsePositiveInt(raw) {
|
|
179
196
|
if (raw == null)
|
|
180
197
|
return undefined;
|
|
@@ -186,6 +203,17 @@ function parsePositiveInt(raw) {
|
|
|
186
203
|
return undefined;
|
|
187
204
|
return n;
|
|
188
205
|
}
|
|
206
|
+
function parseNonNegativeInt(raw) {
|
|
207
|
+
if (raw == null)
|
|
208
|
+
return undefined;
|
|
209
|
+
const trimmed = raw.trim();
|
|
210
|
+
if (trimmed === "")
|
|
211
|
+
return undefined;
|
|
212
|
+
const n = Number(trimmed);
|
|
213
|
+
if (!Number.isInteger(n) || n < 0)
|
|
214
|
+
return undefined;
|
|
215
|
+
return n;
|
|
216
|
+
}
|
|
189
217
|
let registered = false;
|
|
190
218
|
/**
|
|
191
219
|
* Register the embedding backend. Idempotent within a process (mirrors
|
|
@@ -200,6 +228,9 @@ export async function registerEmbeddingsBackend() {
|
|
|
200
228
|
const { register } = await import("harper-fabric-embeddings");
|
|
201
229
|
const modelPath = benchModelPathOverride();
|
|
202
230
|
const threads = resolveEmbedThreads();
|
|
231
|
+
// Omit when unset so HFE's default of 0 is unchanged (flair#1436 measurement
|
|
232
|
+
// pin; #1437 is the default-change decision). Never pass a synthesized default.
|
|
233
|
+
const gpuLayers = resolveEmbedGpuLayers();
|
|
203
234
|
await register({
|
|
204
235
|
logicalName: LOGICAL_NAME,
|
|
205
236
|
kind: "embedding",
|
|
@@ -208,6 +239,7 @@ export async function registerEmbeddingsBackend() {
|
|
|
208
239
|
? { modelPath, pooling: EMBEDDING_POOLING }
|
|
209
240
|
: { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING }),
|
|
210
241
|
threads,
|
|
242
|
+
...(gpuLayers !== undefined ? { gpuLayers } : {}),
|
|
211
243
|
},
|
|
212
244
|
});
|
|
213
245
|
}
|