@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.
@@ -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[, currentTaskHint][, predictedHint] }
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
- if (cost > tokenBudget)
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: [] — no org event in the lookback window was relevant to the
1485
- // caller after zero-row no-op auto-heal filtering (#1200). Present-but-empty
1486
- // by design, not a drop.
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
- ? "No org events in the lookback window were relevant to you (org-wide, or targeted at you) "
1489
- + "after zero-row no-op auto-heal filtering. This container is present-but-empty by design, not dropped."
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" (default: "lessons_learned")
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
- const executePrompt = buildExecutePrompt({ agentId, focus, scope, sinceISO: sinceDate.toISOString(), memories: promptInputs });
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(outcome.candidates, existingPendingClaims);
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 { candidates: responseCandidates, count: responseCandidates.length, model: resolvedModel };
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
- return new Response(JSON.stringify({ error: "invalid_entities", invalid: result.invalid }), { status: 400, headers: { "Content-Type": "application/json" } });
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
  }
@@ -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
- return { ok: true, version: resolveVersion() };
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 package.json — used by `flair
686
- // upgrade`'s post-restart verification (flair#635) to prove the new
687
- // code is actually serving, not just installed on disk.
688
- stats.version = resolveVersion();
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
- // #1207 count arithmetic: included + truncated <= available, for own
826
- // memories AND teammate findings (each a disjoint split of its pool).
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
- { included: "teammateFindingsIncluded", truncated: "teammateFindingsTruncated", available: "teammateFindingsMatched" },
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.