@tpsdev-ai/flair 0.46.0 → 0.47.0
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/dist/build-info.json +6 -0
- package/dist/cli.js +479 -163
- package/dist/doctor-client.js +92 -11
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +12 -2
- package/dist/lib/scheduler-platform.js +153 -0
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +15 -9
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +153 -0
- package/dist/src/rem/scheduler.js +15 -9
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +42 -0
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
|
@@ -78,7 +78,19 @@ import { estimateTokens } from "./token-estimate.js";
|
|
|
78
78
|
* { context, sections, tokenEstimate, maxTokens, memoriesIncluded, memoriesAvailable,
|
|
79
79
|
* memoriesTruncated, teammateFindingsIncluded, teammateFindingsTruncated,
|
|
80
80
|
* teammateFindingsMatched, agentId, scope, soul, memories, predicted,
|
|
81
|
-
* teammateFindings, events
|
|
81
|
+
* teammateFindings, events, soulTokens, memoryTokens, trustTokens,
|
|
82
|
+
* eventsTokens, scaffoldTokens[, currentTaskHint][, predictedHint] }
|
|
83
|
+
*
|
|
84
|
+
* TOKEN LEDGER (flair#1270): the counters decompose `tokenEstimate` from the
|
|
85
|
+
* payload alone —
|
|
86
|
+
* tokenEstimate ≈ scaffoldTokens + soulTokens + memoryTokens + trustTokens
|
|
87
|
+
* + eventsTokens
|
|
88
|
+
* Each figure is measured from what SHIPS (never derived as a residual), so
|
|
89
|
+
* a section that ships uncounted content breaks the identity visibly instead
|
|
90
|
+
* of hiding in an unexplained gap. See the ledger block in the response tail
|
|
91
|
+
* for each counter's definition and the documented ≈ tolerance (the #1207
|
|
92
|
+
* prose-vs-structured measurement decoupling on the connector path; the
|
|
93
|
+
* structured mirror on the prose path).
|
|
82
94
|
* The self-describing keys (flair#1182 part 1) — `agentId` (resolved caller),
|
|
83
95
|
* `scope` (read model applied to the caller), `soul`/`memories`/`predicted`
|
|
84
96
|
* (the caller's OWN records as structured containers), and `currentTaskHint`
|
|
@@ -419,6 +431,14 @@ export class BootstrapMemories extends Resource {
|
|
|
419
431
|
// read below). Declared out here so it is in scope for the response body even
|
|
420
432
|
// if the events read (in a try/catch) yields nothing.
|
|
421
433
|
const includedEvents = [];
|
|
434
|
+
// flair#1298 — count of events that ENTERED the admission loop (relevant to
|
|
435
|
+
// the caller, inside the lookback window, survived dedup + the no-op
|
|
436
|
+
// filter) but were skipped by the budget gate (`cost > tokenBudget`).
|
|
437
|
+
// Feeds the budget-truncated branch of eventsHint, mirroring
|
|
438
|
+
// teammateFindingsTruncated. Deliberately counts ONLY admitted-then-
|
|
439
|
+
// skipped events — never a gap derived from some larger tally, which
|
|
440
|
+
// could imply the existence of rows the caller was not allowed to see.
|
|
441
|
+
let eventsBudgetTruncated = 0;
|
|
422
442
|
const leanMemory = (m, section) => ({
|
|
423
443
|
id: m.id,
|
|
424
444
|
content: m.content,
|
|
@@ -1316,8 +1336,13 @@ export class BootstrapMemories extends Resource {
|
|
|
1316
1336
|
// /mcp path; the prose line is a subset of it). This is the #1199 fix:
|
|
1317
1337
|
// events are content and must be budgeted like content.
|
|
1318
1338
|
const cost = estimateTokens(JSON.stringify(structured));
|
|
1319
|
-
|
|
1339
|
+
// flair#1298 — an event that reached admission but does not fit the
|
|
1340
|
+
// remaining budget is TRUNCATED, not irrelevant; count it so eventsHint
|
|
1341
|
+
// can say so instead of claiming "empty by design" (#1182 contract).
|
|
1342
|
+
if (cost > tokenBudget) {
|
|
1343
|
+
eventsBudgetTruncated++;
|
|
1320
1344
|
continue;
|
|
1345
|
+
}
|
|
1321
1346
|
const elapsed = Date.now() - new Date(evt.createdAt).getTime();
|
|
1322
1347
|
const mins = Math.floor(elapsed / 60_000);
|
|
1323
1348
|
const relTime = mins < 60 ? `${mins}min ago` : `${Math.floor(mins / 60)}h ago`;
|
|
@@ -1350,6 +1375,17 @@ export class BootstrapMemories extends Resource {
|
|
|
1350
1375
|
// teammate finding is EITHER included OR budget-truncated, so this equals their sum.
|
|
1351
1376
|
// Reporting it anchors `teammateFindingsTruncated` as "relevant-but-no-budget"
|
|
1352
1377
|
// rather than an unexplained large number beside a small `included`.
|
|
1378
|
+
//
|
|
1379
|
+
// flair#1290 — INFORMATIONAL-DERIVED, not an independent measurement: this
|
|
1380
|
+
// is computed FROM the two counters it anchors, so `included + truncated
|
|
1381
|
+
// == matched` holds by construction and can never fail. It stays on the
|
|
1382
|
+
// response as a legibility anchor, but it must never serve as the
|
|
1383
|
+
// `available` side of a countCoherence-style invariant — that entry
|
|
1384
|
+
// asserted X <= X, permanently green, and was removed from the bootstrap
|
|
1385
|
+
// contract's invariant array (see resources/mcp-tools.ts; an invariant
|
|
1386
|
+
// that cannot fail is decorative). An independently-measured pre-admission
|
|
1387
|
+
// pool tally was considered and ruled out (K&S on #1290): a new counter
|
|
1388
|
+
// that would itself need an invariant, for marginal gain.
|
|
1353
1389
|
const teammateFindingsMatched = teammateFindingsIncluded + teammateFindingsTruncated;
|
|
1354
1390
|
// --- Build context string ---
|
|
1355
1391
|
const parts = [];
|
|
@@ -1408,6 +1444,34 @@ export class BootstrapMemories extends Resource {
|
|
|
1408
1444
|
: `Structured payload in soul/memories/predicted/teammateFindings `
|
|
1409
1445
|
+ `(${memoriesIncluded} own + ${teammateFindingsIncluded} teammate memories, `
|
|
1410
1446
|
+ `${sections.soul.length} soul entries). Pass includeContext:true for the assembled prose context.`);
|
|
1447
|
+
// ── The payload token LEDGER (flair#1270) ────────────────────────────────
|
|
1448
|
+
//
|
|
1449
|
+
// IDENTITY (documented here because the counters are declared here; the
|
|
1450
|
+
// identity lives in the RESPONSE SCHEMA, not tests-only — a test-only
|
|
1451
|
+
// counter doesn't prevent silent regression, per Kern's #1270 ruling):
|
|
1452
|
+
//
|
|
1453
|
+
// tokenEstimate ≈ scaffoldTokens + soulTokens + memoryTokens
|
|
1454
|
+
// + trustTokens + eventsTokens
|
|
1455
|
+
//
|
|
1456
|
+
// Every token-charged content class has a named counter, so a consumer can
|
|
1457
|
+
// decompose `tokenEstimate` FROM THE PAYLOAD ALONE and any future section
|
|
1458
|
+
// that ships uncounted content shows up as a residual the reported figures
|
|
1459
|
+
// don't explain — the ~1178-token "uncounted" gap the nairmy field rounds
|
|
1460
|
+
// decomposed on 0.44.11/0.44.13 (trust blocks charged at admission since
|
|
1461
|
+
// #1240, but absent from the reported counters) is structurally impossible
|
|
1462
|
+
// to reintroduce silently.
|
|
1463
|
+
//
|
|
1464
|
+
// The ≈ gap on the connector path (includeContext=false) is the documented
|
|
1465
|
+
// measurement/budgeting decoupling (#1207): soulTokens/memoryTokens count
|
|
1466
|
+
// the rendered PROSE lines while the containers ship the heavier
|
|
1467
|
+
// STRUCTURED objects (per-item id/timestamps/keys + JSON string escaping),
|
|
1468
|
+
// so the sum runs BELOW tokenEstimate by that bounded per-item overhead —
|
|
1469
|
+
// never above it (beyond per-line ceil rounding). On the prose path
|
|
1470
|
+
// (includeContext=true) the payload additionally carries the full prose
|
|
1471
|
+
// `context` beside the structured mirror, so the gap legitimately widens by
|
|
1472
|
+
// that mirror — the same documented overage as the CAP CONTRACT above.
|
|
1473
|
+
// None of these counters is a residual: each is measured from what ships,
|
|
1474
|
+
// so the identity CAN fail — that is the point.
|
|
1411
1475
|
const soulTokens = sections.soul.reduce((sum, line) => sum + estimateTokens(line), 0);
|
|
1412
1476
|
// #1199 — memory-line token spend (informational breakdown), independent of
|
|
1413
1477
|
// the reserve/soul now sharing the budget. Sum of the rendered memory lines.
|
|
@@ -1415,6 +1479,18 @@ export class BootstrapMemories extends Resource {
|
|
|
1415
1479
|
...sections.permanent, ...sections.recent, ...sections.predicted,
|
|
1416
1480
|
...sections.relevant, ...sections.teammate,
|
|
1417
1481
|
].reduce((sum, line) => sum + estimateTokens(line), 0);
|
|
1482
|
+
// flair#1270 — the trust array's token spend: Σ estimateTokens(JSON) over
|
|
1483
|
+
// the SHIPPED trust entries. Same builder (buildTrustEntry) and same
|
|
1484
|
+
// formula as the per-entry `trustCost` the five admission sites charged
|
|
1485
|
+
// (#1240), so the reported figure and the admission charge cannot drift.
|
|
1486
|
+
// Always present; 0 when includeTrust is off (the counter convention:
|
|
1487
|
+
// "empty" is reported, never absent).
|
|
1488
|
+
const trustTokens = includedTrustMemories.reduce((sum, entry) => sum + estimateTokens(JSON.stringify(entry)), 0);
|
|
1489
|
+
// flair#1270 — the events container's token spend: Σ estimateTokens(JSON)
|
|
1490
|
+
// over the SHIPPED structured events — exactly the per-event cost the
|
|
1491
|
+
// events admission loop charged (#1199). Same reporting hole-class as
|
|
1492
|
+
// trust: charged content with no counter would be invisible in the ledger.
|
|
1493
|
+
const eventsTokens = includedEvents.reduce((sum, evt) => sum + estimateTokens(JSON.stringify(evt)), 0);
|
|
1418
1494
|
// flair#744 slice 1 — opt-in per-memory trust block. Bootstrap renders
|
|
1419
1495
|
// memories as text lines rather than result objects, so the block is
|
|
1420
1496
|
// surfaced as a `trust` array of self-contained entries (each carries its
|
|
@@ -1481,12 +1557,19 @@ export class BootstrapMemories extends Resource {
|
|
|
1481
1557
|
// fills it, so "deliberately empty" is never confused with "silently
|
|
1482
1558
|
// dropped". Present ONLY when the container is empty (a populated container
|
|
1483
1559
|
// needs no hint), so a healthy payload is unchanged.
|
|
1484
|
-
// events: [] —
|
|
1485
|
-
//
|
|
1486
|
-
//
|
|
1560
|
+
// events: [] — name WHICH empty this is (flair#1298, mirroring the
|
|
1561
|
+
// teammateFindingsHint branches below): relevant-but-budget-truncated
|
|
1562
|
+
// (events cleared the lookback window and every relevance filter but none
|
|
1563
|
+
// fit the remaining token budget — saying "by design" there is exactly the
|
|
1564
|
+
// false reassurance the #1182 hint contract forbids), or genuinely no
|
|
1565
|
+
// relevant event in the window after zero-row no-op auto-heal filtering
|
|
1566
|
+
// (#1200) — present-but-empty by design, not a drop.
|
|
1487
1567
|
const eventsHint = includedEvents.length === 0
|
|
1488
|
-
?
|
|
1489
|
-
|
|
1568
|
+
? (eventsBudgetTruncated > 0
|
|
1569
|
+
? `No org events fit the token budget: ${eventsBudgetTruncated} relevant event(s) cleared the `
|
|
1570
|
+
+ "lookback window but were budget-truncated. Raise maxTokens to include them."
|
|
1571
|
+
: "No org events in the lookback window were relevant to you (org-wide, or targeted at you) "
|
|
1572
|
+
+ "after zero-row no-op auto-heal filtering. This container is present-but-empty by design, not dropped.")
|
|
1490
1573
|
: undefined;
|
|
1491
1574
|
// teammateFindings: [] — name WHICH legitimate empty this is (no task → no
|
|
1492
1575
|
// retrieval; matched-but-budget-truncated; or no cross-agent record entered
|
|
@@ -1549,6 +1632,11 @@ export class BootstrapMemories extends Resource {
|
|
|
1549
1632
|
},
|
|
1550
1633
|
soulTokens,
|
|
1551
1634
|
memoryTokens,
|
|
1635
|
+
// flair#1270 — the remaining ledger terms (see the IDENTITY block above,
|
|
1636
|
+
// where these counters are computed): trust and events content, so every
|
|
1637
|
+
// token-charged content class is decomposable from the payload alone.
|
|
1638
|
+
trustTokens,
|
|
1639
|
+
eventsTokens,
|
|
1552
1640
|
// flair#1199 — the content-selection budget this response was built
|
|
1553
1641
|
// against (echoed so a connector can relate tokenEstimate to the budget it
|
|
1554
1642
|
// asked for — and so the conformance tokenEstimate<=maxTokens invariant is
|
|
@@ -1598,6 +1686,29 @@ export class BootstrapMemories extends Resource {
|
|
|
1598
1686
|
// regression (it dropped relevant findings, charging a flat per-item overhead
|
|
1599
1687
|
// against the content budget). If the real payload consistently overruns for
|
|
1600
1688
|
// a use case, raise `maxTokens`.
|
|
1689
|
+
// flair#1270 — scaffoldTokens: the fixed structural frame, measured
|
|
1690
|
+
// DIRECTLY (never derived as tokenEstimate-minus-the-other-counters — a
|
|
1691
|
+
// residual would silently absorb any future uncounted content, defeating
|
|
1692
|
+
// the ledger's whole purpose). Measured as the serialized body with every
|
|
1693
|
+
// CONTENT container emptied: what remains is exactly the module-doc's
|
|
1694
|
+
// "fixed JSON scaffolding" — container keys/braces, the counters, the
|
|
1695
|
+
// sections map, scope, hints. Cheap: one JSON.stringify of a small object.
|
|
1696
|
+
// The spread-then-override keeps key order identical to the delivered
|
|
1697
|
+
// body, so a consumer can reconstruct this figure exactly from the
|
|
1698
|
+
// payload: empty the same content fields, drop scaffoldTokens /
|
|
1699
|
+
// tokenEstimate / the wrapper-injected flairVersion (all appended AFTER
|
|
1700
|
+
// this measurement), and re-run the same estimator.
|
|
1701
|
+
const scaffoldSkeleton = {
|
|
1702
|
+
...responseBody,
|
|
1703
|
+
context: "",
|
|
1704
|
+
soul: {},
|
|
1705
|
+
memories: [],
|
|
1706
|
+
predicted: [],
|
|
1707
|
+
teammateFindings: [],
|
|
1708
|
+
events: [],
|
|
1709
|
+
...(trust ? { trust: [] } : {}),
|
|
1710
|
+
};
|
|
1711
|
+
responseBody.scaffoldTokens = estimateTokens(JSON.stringify(scaffoldSkeleton));
|
|
1601
1712
|
const tokenEstimate = estimateTokens(JSON.stringify(responseBody));
|
|
1602
1713
|
return { ...responseBody, tokenEstimate };
|
|
1603
1714
|
}
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
* scope string — "recent" | "tagged" | "all" (default: "recent")
|
|
13
13
|
* since string? — ISO timestamp lower bound (default: 24h ago)
|
|
14
14
|
* maxMemories number? — cap (default: 50)
|
|
15
|
-
* focus string? — "lessons_learned" | "patterns" | "decisions" | "errors"
|
|
15
|
+
* focus string? — "lessons_learned" | "patterns" | "decisions" | "errors" | "continuity"
|
|
16
|
+
* (default: "lessons_learned"; a continuity-tag run — scope="tagged" with an
|
|
17
|
+
* adk:continuity:* tag — always uses "continuity", flair#1257 slice 3)
|
|
16
18
|
* tag string? — required when scope="tagged"
|
|
17
19
|
* execute boolean? — default false. When true, distill server-side and
|
|
18
20
|
* stage MemoryCandidate rows instead of returning a prompt.
|
|
@@ -27,6 +29,8 @@
|
|
|
27
29
|
* candidates MemoryCandidate[] — staged rows (rationalePrompt omitted — see below)
|
|
28
30
|
* count number
|
|
29
31
|
* model string — resolved model id (see generatedBy note below)
|
|
32
|
+
* droppedStaleIntent number? — continuity runs only (flair#1257 slice 3): candidates
|
|
33
|
+
* dropped by the stale-intent post-filter
|
|
30
34
|
*
|
|
31
35
|
* The pure logic behind execute mode (prompt building, actor resolution,
|
|
32
36
|
* generate+validate+retry, dedup) lives in ./memory-reflect-lib.ts — see that
|
|
@@ -40,7 +44,7 @@ import { Resource, databases, models, logger } from "harper";
|
|
|
40
44
|
import { randomBytes } from "node:crypto";
|
|
41
45
|
import { isAdmin, allowVerified } from "./agent-auth.js";
|
|
42
46
|
import { patchRecordSilent } from "./table-helpers.js";
|
|
43
|
-
import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, } from "./memory-reflect-lib.js";
|
|
47
|
+
import { buildReflectionPrompt, buildExecutePrompt, resolveReflectActor, generateCandidates, dedupeCandidates, memoryMatchesReflectScope, buildStagedCandidateRow, isContinuityScopeTag, filterStaleSessionIntentCandidates, resolveCandidateVisibilityRuling, DEFAULT_STALE_INTENT_HORIZON_MS, } from "./memory-reflect-lib.js";
|
|
44
48
|
export class ReflectMemories extends Resource {
|
|
45
49
|
// Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
|
|
46
50
|
// admin elevation). Any verified agent may reflect; the isAdmin checks in post()
|
|
@@ -67,6 +71,7 @@ export class ReflectMemories extends Resource {
|
|
|
67
71
|
}
|
|
68
72
|
const agentId = actorResolution.agentId;
|
|
69
73
|
const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
|
|
74
|
+
const gatherNow = new Date();
|
|
70
75
|
const memories = [];
|
|
71
76
|
for await (const record of databases.flair.Memory.search()) {
|
|
72
77
|
if (record.agentId !== agentId)
|
|
@@ -75,6 +80,14 @@ export class ReflectMemories extends Resource {
|
|
|
75
80
|
continue;
|
|
76
81
|
if (record.durability === "permanent")
|
|
77
82
|
continue; // permanent memories don't need reflection
|
|
83
|
+
// flair#1257 slice 3: never gather a TTL-expired row. The reap
|
|
84
|
+
// (MemoryMaintenance) is asynchronous, so an ephemeral row whose
|
|
85
|
+
// expiresAt is past can still be in storage — distilling it would
|
|
86
|
+
// resurrect exactly what the TTL killed (same rule the continuity
|
|
87
|
+
// resume path applies on read, packages/flair-mcp/src/continuity.ts
|
|
88
|
+
// isLive; scenario S4: expired rows are excluded even before the reap).
|
|
89
|
+
if (typeof record.expiresAt === "string" && record.expiresAt !== "" && new Date(record.expiresAt) <= gatherNow)
|
|
90
|
+
continue;
|
|
78
91
|
// Scope selection — the cross-user-bleed boundary (#1205b-1). See
|
|
79
92
|
// memoryMatchesReflectScope's doc: scope:"tagged" admits ONLY the one
|
|
80
93
|
// adk:<app>:<user> tag's memories, so a candidate distilled here can
|
|
@@ -111,7 +124,31 @@ export class ReflectMemories extends Resource {
|
|
|
111
124
|
};
|
|
112
125
|
}
|
|
113
126
|
// ── execute mode (spec §3A) ─────────────────────────────────────────────
|
|
114
|
-
|
|
127
|
+
// flair#1257 slice 3 — continuity-journal runs. A scope:"tagged" run whose
|
|
128
|
+
// tag is a continuity session tag (`adk:continuity:<sessionId>`) gets the
|
|
129
|
+
// continuity guard set: the continuity focus prompt (the stale-intent
|
|
130
|
+
// prompt rule is a Kern-ruled guard, so it is UNCONDITIONAL for continuity
|
|
131
|
+
// runs — a caller-supplied focus cannot switch it off), the visibility
|
|
132
|
+
// ruling contract in the prompt, and the stale-intent post-filter below.
|
|
133
|
+
const isContinuityRun = scope === "tagged" && isContinuityScopeTag(tag);
|
|
134
|
+
const effectiveFocus = isContinuityRun ? "continuity" : focus;
|
|
135
|
+
// Session age = the NEWEST gathered entry's createdAt (memories are sorted
|
|
136
|
+
// ascending above). Staleness horizon: default 72h, FLAIR_REM_STALE_INTENT_HOURS.
|
|
137
|
+
const sessionNewestCreatedAt = memories.length > 0 ? memories[memories.length - 1].createdAt : undefined;
|
|
138
|
+
const staleHorizonHours = Number(process.env.FLAIR_REM_STALE_INTENT_HOURS);
|
|
139
|
+
const staleHorizonMs = Number.isFinite(staleHorizonHours) && staleHorizonHours > 0
|
|
140
|
+
? staleHorizonHours * 3600_000
|
|
141
|
+
: DEFAULT_STALE_INTENT_HORIZON_MS;
|
|
142
|
+
const executeNow = new Date();
|
|
143
|
+
const sessionStale = !sessionNewestCreatedAt || executeNow.getTime() - new Date(sessionNewestCreatedAt).getTime() > staleHorizonMs;
|
|
144
|
+
const executePrompt = buildExecutePrompt({
|
|
145
|
+
agentId,
|
|
146
|
+
focus: effectiveFocus,
|
|
147
|
+
scope,
|
|
148
|
+
sinceISO: sinceDate.toISOString(),
|
|
149
|
+
memories: promptInputs,
|
|
150
|
+
...(isContinuityRun ? { continuity: { sessionStale } } : {}),
|
|
151
|
+
});
|
|
115
152
|
const gatheredMemoryIds = new Set(promptInputs.map((m) => m.id));
|
|
116
153
|
const configuredModel = process.env.FLAIR_REM_MODEL || undefined;
|
|
117
154
|
const outcome = await generateCandidates({
|
|
@@ -130,6 +167,21 @@ export class ReflectMemories extends Resource {
|
|
|
130
167
|
if (outcome.usedJsonFallback) {
|
|
131
168
|
logger.warn?.(`MemoryReflect: json-fallback path active for agent ${agentId} (schema-mode output failed validation)`);
|
|
132
169
|
}
|
|
170
|
+
// flair#1257 slice 3 — the stale-intent POST-FILTER (the testable second
|
|
171
|
+
// layer of the two-layer guard; the prompt rule above is the primary).
|
|
172
|
+
// Runs AFTER validation (a drop is a policy skip, never a batch failure)
|
|
173
|
+
// and BEFORE dedup/staging. Only continuity runs are filtered — for every
|
|
174
|
+
// other run this is a straight pass-through.
|
|
175
|
+
const staleIntentResult = isContinuityRun
|
|
176
|
+
? filterStaleSessionIntentCandidates(outcome.candidates, {
|
|
177
|
+
sessionNewestCreatedAt,
|
|
178
|
+
now: executeNow,
|
|
179
|
+
horizonMs: staleHorizonMs,
|
|
180
|
+
})
|
|
181
|
+
: { kept: outcome.candidates, droppedStaleIntent: [] };
|
|
182
|
+
if (staleIntentResult.droppedStaleIntent.length > 0) {
|
|
183
|
+
logger.warn?.(`MemoryReflect: stale-intent filter dropped ${staleIntentResult.droppedStaleIntent.length} candidate(s) for agent ${agentId} (stale continuity session)`);
|
|
184
|
+
}
|
|
133
185
|
// Dedup against this agent's existing pending candidates (spec §3A item 4).
|
|
134
186
|
const existingPendingClaims = [];
|
|
135
187
|
for await (const c of databases.flair.MemoryCandidate.search({})) {
|
|
@@ -139,7 +191,7 @@ export class ReflectMemories extends Resource {
|
|
|
139
191
|
continue;
|
|
140
192
|
existingPendingClaims.push(c.claim);
|
|
141
193
|
}
|
|
142
|
-
const toStage = dedupeCandidates(
|
|
194
|
+
const toStage = dedupeCandidates(staleIntentResult.kept, existingPendingClaims);
|
|
143
195
|
// generatedBy: GenerateResult in the pinned harper 5.1.17 has
|
|
144
196
|
// no model/backend-id field (content/finishReason/usage/toolCalls/trace
|
|
145
197
|
// only) — the "from the generate result if available" branch is
|
|
@@ -163,6 +215,12 @@ export class ReflectMemories extends Resource {
|
|
|
163
215
|
generatedAt,
|
|
164
216
|
scope,
|
|
165
217
|
tag,
|
|
218
|
+
// flair#1257 slice 3: record an AFFIRMATIVE shared ruling (with its
|
|
219
|
+
// team-relevance justification) on the candidate — continuity runs
|
|
220
|
+
// only. resolveCandidateVisibilityRuling returns null for anything
|
|
221
|
+
// less than shared+justified, and null stamps nothing: the promoted
|
|
222
|
+
// row then defaults private (Sherlock's default-private-unless).
|
|
223
|
+
visibilityRuling: isContinuityRun ? resolveCandidateVisibilityRuling(c) : null,
|
|
166
224
|
});
|
|
167
225
|
await databases.flair.MemoryCandidate.put(row);
|
|
168
226
|
staged.push(row);
|
|
@@ -173,6 +231,13 @@ export class ReflectMemories extends Resource {
|
|
|
173
231
|
// candidate would just repeat the same large string N times. Matches
|
|
174
232
|
// `flair rem candidates`' own listing, which doesn't surface it either.
|
|
175
233
|
const responseCandidates = staged.map(({ rationalePrompt, ...rest }) => rest);
|
|
176
|
-
return {
|
|
234
|
+
return {
|
|
235
|
+
candidates: responseCandidates,
|
|
236
|
+
count: responseCandidates.length,
|
|
237
|
+
model: resolvedModel,
|
|
238
|
+
// flair#1257 slice 3: continuity-run observability — how many candidates
|
|
239
|
+
// the stale-intent post-filter dropped (0 for non-continuity runs).
|
|
240
|
+
...(isContinuityRun ? { droppedStaleIntent: staleIntentResult.droppedStaleIntent.length } : {}),
|
|
241
|
+
};
|
|
177
242
|
}
|
|
178
243
|
}
|
|
@@ -123,6 +123,52 @@ export function decideAutoPromote(candidate) {
|
|
|
123
123
|
export function isMachineReviewerId(id) {
|
|
124
124
|
return typeof id === "string" && id.startsWith(MACHINE_REVIEWER_PREFIX);
|
|
125
125
|
}
|
|
126
|
+
// ─── Promoted-row visibility (flair#1257 slice 3 — default-private-unless) ────
|
|
127
|
+
// Continuity-journal scope tag prefix. Canonical string duplicated in
|
|
128
|
+
// resources/memory-reflect-lib.ts (CONTINUITY_SCOPE_TAG_PREFIX), packages/
|
|
129
|
+
// flair-mcp/src/continuity.ts (the writer) and src/rem/runner.ts — kept in
|
|
130
|
+
// sync by the shared canonical string across the npm-packaging boundaries
|
|
131
|
+
// (same discipline as MACHINE_REVIEWER_* above).
|
|
132
|
+
export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
|
|
133
|
+
/** True iff `tag` is a continuity-journal scope tag (non-empty sessionId
|
|
134
|
+
* component — the bare prefix is not a session). */
|
|
135
|
+
export function isContinuityScopeTag(tag) {
|
|
136
|
+
return typeof tag === "string" && tag.length > CONTINUITY_SCOPE_TAG_PREFIX.length && tag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Decide a promoted Memory row's visibility (Sherlock's ruling, flair#1257
|
|
140
|
+
* slice 3): **default-private-unless**. Promotion is a visibility ESCALATION
|
|
141
|
+
* from the most sensitive tier (the sources are ephemeral+private journal
|
|
142
|
+
* rows, or standard+private session episodes), so the DEFAULT — including
|
|
143
|
+
* every uncertainty fallback — is "private". "shared" is returned ONLY when
|
|
144
|
+
* ALL of:
|
|
145
|
+
*
|
|
146
|
+
* 1. the candidate is CONTINUITY-scoped (scopeTag `adk:continuity:*`).
|
|
147
|
+
* ADK per-user candidates (`adk:<app>:<user>`) are ALWAYS private —
|
|
148
|
+
* their per-user isolation is client-side tag re-verification that
|
|
149
|
+
* other agents don't run, so a shared ADK promotion leaks a user's
|
|
150
|
+
* distilled private data org-wide (the #1205b-2 safety argument). No
|
|
151
|
+
* ruling can override that; and
|
|
152
|
+
* 2. the distiller AFFIRMATIVELY ruled "shared" (visibilityRuling —
|
|
153
|
+
* stamped at staging only when the model emitted an explicit shared
|
|
154
|
+
* ruling, resources/memory-reflect-lib.ts); and
|
|
155
|
+
* 3. a non-empty team-relevance justification is recorded on the candidate
|
|
156
|
+
* (visibilityRationale). A shared ruling without its justification is
|
|
157
|
+
* not affirmative — it decays to private, fail-closed.
|
|
158
|
+
*
|
|
159
|
+
* So a shared promoted row always traces to a recorded justification on its
|
|
160
|
+
* candidate — never to a default, never silently.
|
|
161
|
+
*/
|
|
162
|
+
export function decidePromotedVisibility(candidate) {
|
|
163
|
+
if (!isContinuityScopeTag(candidate.scopeTag))
|
|
164
|
+
return "private";
|
|
165
|
+
if (candidate.visibilityRuling !== "shared")
|
|
166
|
+
return "private";
|
|
167
|
+
const rationale = typeof candidate.visibilityRationale === "string" ? candidate.visibilityRationale.trim() : "";
|
|
168
|
+
if (rationale.length === 0)
|
|
169
|
+
return "private";
|
|
170
|
+
return "shared";
|
|
171
|
+
}
|
|
126
172
|
/**
|
|
127
173
|
* The tag set for an auto-promoted Memory. The per-user `scopeTag` MUST come
|
|
128
174
|
* first and is load-bearing — it is the access-control boundary that keeps the
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* build-info.ts — read back the build-identity stamp the build wrote
|
|
3
|
+
* (flair#1076; the stamp itself is written by scripts/write-build-info.mjs
|
|
4
|
+
* at the end of both `build` and `build:cli`).
|
|
5
|
+
*
|
|
6
|
+
* WHY THE PATH IS MODULE-RELATIVE. The point of the stamp (Kern's ruling on
|
|
7
|
+
* #1076) is that the RUNNING server reports its own build identity — a
|
|
8
|
+
* file-only check proves the artifact on disk, not what the server loaded
|
|
9
|
+
* (the 0.25.0 stale-dist incident class). Compiled, this module executes as
|
|
10
|
+
* `dist/resources/build-info.js`, so `../build-info.json` is the stamp
|
|
11
|
+
* emitted by the very build whose modules Harper loaded (config.yaml's
|
|
12
|
+
* `jsResource: dist/resources/*.js`) — the same "resolve relative to THIS
|
|
13
|
+
* running module" idiom as resolveVersion() in resources/version.ts.
|
|
14
|
+
*
|
|
15
|
+
* When this module runs from SOURCE (a bun test importing resources/*.ts
|
|
16
|
+
* directly), `../build-info.json` is the repo root, where no stamp exists —
|
|
17
|
+
* the resolver returns null and callers fall back honestly (version from
|
|
18
|
+
* package.json, buildCommit served as null). A source run has no build, so
|
|
19
|
+
* it HAS no build identity; reaching over into dist/ would report someone
|
|
20
|
+
* else's.
|
|
21
|
+
*
|
|
22
|
+
* Read per call, not cached: identical lifecycle to resolveVersion()'s
|
|
23
|
+
* package.json read, and /Health stays a truthful view of the file rather
|
|
24
|
+
* than of whichever request happened to arrive first.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { join, dirname } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
export function resolveBuildInfo() {
|
|
30
|
+
try {
|
|
31
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const stampPath = join(here, "..", "build-info.json");
|
|
33
|
+
if (!existsSync(stampPath))
|
|
34
|
+
return null;
|
|
35
|
+
const raw = JSON.parse(readFileSync(stampPath, "utf-8"));
|
|
36
|
+
if (!raw || typeof raw !== "object")
|
|
37
|
+
return null;
|
|
38
|
+
// Per-field validation, not a whole-object trust: a malformed stamp
|
|
39
|
+
// degrades field-by-field to null rather than inventing values.
|
|
40
|
+
return {
|
|
41
|
+
version: typeof raw.version === "string" && raw.version.length > 0 ? raw.version : null,
|
|
42
|
+
commit: typeof raw.commit === "string" && /^[0-9a-f]{40}$/.test(raw.commit) ? raw.commit : null,
|
|
43
|
+
builtAt: typeof raw.builtAt === "string" ? raw.builtAt : null,
|
|
44
|
+
builder: typeof raw.builder === "string" ? raw.builder : null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null; // unreadable/corrupt stamp — same honest fallback as absent
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -107,6 +107,23 @@ export function isValidEntity(entity) {
|
|
|
107
107
|
return false;
|
|
108
108
|
return VALUE_VALIDATORS[parsed.type](parsed.value);
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Canonical "what does well-formed look like" hint for invalid_entity /
|
|
112
|
+
* invalid_entities rejections (flair#1288, nightly-canary finding 5): every
|
|
113
|
+
* rejection of an entity string must name the `type:value` format AND
|
|
114
|
+
* enumerate the closed type set — errors must enable a response. Built from
|
|
115
|
+
* ENTITY_TYPES so the enumeration can never drift from the vocabulary it
|
|
116
|
+
* describes.
|
|
117
|
+
*
|
|
118
|
+
* The CLI ships an inlined copy of this module for its client-side pre-check
|
|
119
|
+
* (src/lib/entity-vocab-cli.ts — cross-boundary imports from src/ into
|
|
120
|
+
* resources/ don't survive npm packaging; same reason src/cli.ts inlines the
|
|
121
|
+
* federation crypto helpers). test/unit/cli-entities-option.test.ts pins the
|
|
122
|
+
* two implementations together, this hint string included.
|
|
123
|
+
*/
|
|
124
|
+
export function entityFormatHint() {
|
|
125
|
+
return `entities are 'type:value' vocabulary strings (e.g. 'repo:owner/name'); valid types: ${ENTITY_TYPES.join(", ")}`;
|
|
126
|
+
}
|
|
110
127
|
/**
|
|
111
128
|
* Validate an `entities` field value (expected: string[] | undefined | null).
|
|
112
129
|
* `undefined`/`null` is treated as valid — the field is additive/optional,
|
|
@@ -135,5 +152,12 @@ export function invalidEntitiesResponse(entities) {
|
|
|
135
152
|
const result = validateEntities(entities);
|
|
136
153
|
if (result.valid)
|
|
137
154
|
return null;
|
|
138
|
-
|
|
155
|
+
// `message` is additive (flair#1288) — `error` and `invalid` keep their
|
|
156
|
+
// exact shapes for existing consumers; the new field makes the rejection
|
|
157
|
+
// actionable on its own (names the format, enumerates the valid types).
|
|
158
|
+
return new Response(JSON.stringify({
|
|
159
|
+
error: "invalid_entities",
|
|
160
|
+
invalid: result.invalid,
|
|
161
|
+
message: `invalid entities: ${result.invalid.join(", ")} — ${entityFormatHint()}`,
|
|
162
|
+
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
139
163
|
}
|
package/dist/resources/health.js
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir, platform } from "node:os";
|
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
7
|
+
import { resolveBuildInfo } from "./build-info.js";
|
|
7
8
|
import { getMigrationStatusSnapshot } from "./migrations/status.js";
|
|
8
9
|
import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
|
|
9
10
|
import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
|
|
@@ -85,7 +86,21 @@ export class Health extends Resource {
|
|
|
85
86
|
// PUBLICLY over Fabric, the public surface must omit this field (the
|
|
86
87
|
// richer, auth-gated /HealthDetail already carried version pre-existing —
|
|
87
88
|
// this only adds it to the anonymous endpoint too).
|
|
88
|
-
|
|
89
|
+
//
|
|
90
|
+
// flair#1076: both values come from dist/build-info.json — the stamp the
|
|
91
|
+
// build wrote next to the modules Harper actually loaded — so /Health
|
|
92
|
+
// reports the identity of the SERVED code, not of whatever package.json
|
|
93
|
+
// sits on disk (the 0.25.0 stale-dist incident class; Kern's ruling).
|
|
94
|
+
// `version` falls back to the package.json resolver only when no stamp
|
|
95
|
+
// exists (source runs, pre-#1076 dists). `buildCommit` is ALWAYS present:
|
|
96
|
+
// a 40-hex sha when the build ran in a git work tree, an honest null
|
|
97
|
+
// otherwise (tarball builds) — never omitted, never fabricated (Sherlock).
|
|
98
|
+
const build = resolveBuildInfo();
|
|
99
|
+
return {
|
|
100
|
+
ok: true,
|
|
101
|
+
version: build?.version ?? resolveVersion(),
|
|
102
|
+
buildCommit: build?.commit ?? null,
|
|
103
|
+
};
|
|
89
104
|
}
|
|
90
105
|
}
|
|
91
106
|
/**
|
|
@@ -682,10 +697,15 @@ export class HealthDetail extends Resource {
|
|
|
682
697
|
// ── Warnings ──
|
|
683
698
|
stats.warnings = warnings;
|
|
684
699
|
// ── Process info ──
|
|
685
|
-
// version: the RUNNING process's own
|
|
686
|
-
// upgrade`'s post-restart verification
|
|
687
|
-
// code is actually serving, not just
|
|
688
|
-
|
|
700
|
+
// version: the RUNNING process's own build stamp (dist/build-info.json,
|
|
701
|
+
// flair#1076) — used by `flair upgrade`'s post-restart verification
|
|
702
|
+
// (flair#635) to prove the new code is actually serving, not just
|
|
703
|
+
// installed on disk. Falls back to the package.json resolver when no
|
|
704
|
+
// stamp exists; buildCommit mirrors /Health's field (always present,
|
|
705
|
+
// null when built outside a git work tree).
|
|
706
|
+
const build = resolveBuildInfo();
|
|
707
|
+
stats.version = build?.version ?? resolveVersion();
|
|
708
|
+
stats.buildCommit = build?.commit ?? null;
|
|
689
709
|
stats.pid = process.pid;
|
|
690
710
|
stats.uptimeSeconds = Math.floor(process.uptime());
|
|
691
711
|
return stats;
|
|
@@ -788,6 +788,11 @@ export const TOOLS = {
|
|
|
788
788
|
"sections", "tokenEstimate", "maxTokens", "memoriesIncluded", "memoriesAvailable",
|
|
789
789
|
"memoriesTruncated", "teammateFindingsIncluded", "teammateFindingsTruncated",
|
|
790
790
|
"teammateFindingsMatched", "context", "flairVersion",
|
|
791
|
+
// flair#1270 — the payload token LEDGER: every token-charged content
|
|
792
|
+
// class has a counter, so tokenEstimate ≈ scaffoldTokens + soulTokens +
|
|
793
|
+
// memoryTokens + trustTokens + eventsTokens decomposes from the payload
|
|
794
|
+
// alone (see the identity block in MemoryBootstrap's response tail).
|
|
795
|
+
"soulTokens", "memoryTokens", "trustTokens", "eventsTokens", "scaffoldTokens",
|
|
791
796
|
],
|
|
792
797
|
fieldTypes: {
|
|
793
798
|
agentId: "string", soul: "object", memories: "array", predicted: "array",
|
|
@@ -796,6 +801,8 @@ export const TOOLS = {
|
|
|
796
801
|
memoriesAvailable: "number", memoriesTruncated: "number",
|
|
797
802
|
teammateFindingsIncluded: "number", teammateFindingsTruncated: "number",
|
|
798
803
|
teammateFindingsMatched: "number", context: "string", flairVersion: "string",
|
|
804
|
+
soulTokens: "number", memoryTokens: "number", trustTokens: "number",
|
|
805
|
+
eventsTokens: "number", scaffoldTokens: "number",
|
|
799
806
|
},
|
|
800
807
|
invariants: {
|
|
801
808
|
// count == delivered — the historical count/charge/deliver drift.
|
|
@@ -822,12 +829,55 @@ export const TOOLS = {
|
|
|
822
829
|
// tolerance covers the fixed JSON scaffolding + the #1207 prose-vs-
|
|
823
830
|
// structured charge gap; uncounted content does not fit under it.
|
|
824
831
|
budgetCap: { estimate: "tokenEstimate", budget: "maxTokens", tolerance: 0.25 },
|
|
825
|
-
// #
|
|
826
|
-
//
|
|
832
|
+
// flair#1290 step 4 — the #1270 token-ledger identity, enforced:
|
|
833
|
+
// tokenEstimate ≈ scaffold + soul + memory + trust + events, two-sided
|
|
834
|
+
// (constants sized in bootstrap-token-ledger-1270.test.ts, which reads
|
|
835
|
+
// them from HERE — one identity, one tolerance definition). The upper
|
|
836
|
+
// bound is waived when the request opted into the prose mirror
|
|
837
|
+
// (includeContext), which legitimately widens the gap by design.
|
|
838
|
+
tokenDecomposition: {
|
|
839
|
+
total: "tokenEstimate",
|
|
840
|
+
terms: ["scaffoldTokens", "soulTokens", "memoryTokens", "trustTokens", "eventsTokens"],
|
|
841
|
+
perItemContainers: ["memories", "predicted", "teammateFindings"],
|
|
842
|
+
perItemGap: 60,
|
|
843
|
+
fixedSlack: 150,
|
|
844
|
+
roundingSlack: 48,
|
|
845
|
+
proseMirrorArg: "includeContext",
|
|
846
|
+
},
|
|
847
|
+
// #1207 — count arithmetic: included + truncated <= available. The
|
|
848
|
+
// own-memory triple CAN fail: memoriesAvailable comes from an
|
|
849
|
+
// independent count query, so a double-counted memory breaks it.
|
|
827
850
|
countCoherence: [
|
|
828
851
|
{ included: "memoriesIncluded", truncated: "memoriesTruncated", available: "memoriesAvailable" },
|
|
829
|
-
|
|
852
|
+
// flair#1290 — the teammate triple ({ teammateFindingsIncluded,
|
|
853
|
+
// teammateFindingsTruncated, teammateFindingsMatched }) is
|
|
854
|
+
// deliberately NOT here. teammateFindingsMatched is DEFINED as
|
|
855
|
+
// included + truncated (informational-derived — see the note at its
|
|
856
|
+
// definition in resources/MemoryBootstrap.ts), so the entry asserted
|
|
857
|
+
// X <= X: permanently green by construction. An invariant that
|
|
858
|
+
// cannot fail is decorative, and a decorative entry in this array
|
|
859
|
+
// misrepresents the coverage the suite provides. K&S ruling on
|
|
860
|
+
// #1290: drop it rather than adding an independently-measured pool
|
|
861
|
+
// tally (a new counter that would itself need an invariant); the
|
|
862
|
+
// trust path's real protections are budgetCap plus the own-memory
|
|
863
|
+
// triple above, now exercised under includeTrust:true.
|
|
864
|
+
],
|
|
865
|
+
// flair#1290 — populated-or-hint (#1182's 0.44.11 rule, previously
|
|
866
|
+
// asserted nowhere): every structured container that ships empty
|
|
867
|
+
// carries its "why" hint, and no hint ships beside a populated
|
|
868
|
+
// container. Conditions per the hintWhenEmpty type doc — predictedHint
|
|
869
|
+
// additionally requires subjects to have been requested;
|
|
870
|
+
// currentTaskHint keys off the request, not a container.
|
|
871
|
+
hintWhenEmpty: [
|
|
872
|
+
{ hint: "eventsHint", container: "events" },
|
|
873
|
+
{ hint: "teammateFindingsHint", container: "teammateFindings" },
|
|
874
|
+
{ hint: "predictedHint", container: "predicted", requiresNonEmptyArrayArg: "subjects" },
|
|
875
|
+
{ hint: "currentTaskHint", presentWhenStringArgBlank: "currentTask" },
|
|
830
876
|
],
|
|
877
|
+
// flair#1290 — nothing the wrapper's own classifier calls a zero-row
|
|
878
|
+
// no-op ships as an event (#1200 render filter as a semantic class,
|
|
879
|
+
// not hardcoded fixture strings).
|
|
880
|
+
noOpEventsSuppressed: { container: "events" },
|
|
831
881
|
// #1199 — prose is a pointer at the default, not a second copy.
|
|
832
882
|
proseContextIsPointerAtDefault: { field: "context" },
|
|
833
883
|
// shape of the structured containers a connector reads; #1188 leak bites on memories.
|