@tpsdev-ai/flair 0.12.0 → 0.14.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/cli.js CHANGED
@@ -510,35 +510,11 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
510
510
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
511
511
  }
512
512
  }
513
- // Seed an agent record via the Harper REST API.
514
- // Uses localhost and the given HTTP port.
515
- export async function seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass) {
516
- const baseUrl = `http://127.0.0.1:${httpPort}`;
517
- const body = {
518
- operation: "insert",
519
- database: "flair",
520
- table: "Agent",
521
- records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
522
- };
523
- // Only send Authorization header if adminPass is provided.
524
- // Matches the auth pattern in api() which respects authorizeLocal=true.
525
- const headers = { "Content-Type": "application/json" };
526
- if (adminPass) {
527
- headers.Authorization = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
528
- }
529
- const res = await fetch(`${baseUrl}/Agent`, {
530
- method: "POST",
531
- headers,
532
- body: JSON.stringify(body),
533
- signal: AbortSignal.timeout(10_000),
534
- });
535
- if (!res.ok) {
536
- const text = await res.text().catch(() => "");
537
- if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
538
- return;
539
- throw new Error(`REST API insert failed (${res.status}): ${text}`);
540
- }
541
- }
513
+ // NOTE: agent records are seeded exclusively via the Harper operations API
514
+ // (seedAgentViaOpsApi above). A former seedAgentViaRestApi() helper POSTed the
515
+ // ops-insert body to the REST root, which Harper 405s as a collection POST to
516
+ // /Agent (the Agent table resource has no POST handler). It was removed in the
517
+ // #499 fix; do not reintroduce a REST-root insert path.
542
518
  // ─── FederationInstance seed via ops API ──────────────────────────────────────
543
519
  //
544
520
  // Remote init writes FederationInstance through the ops API (Basic auth with
@@ -1980,8 +1956,21 @@ program
1980
1956
  writeConfig(httpPort);
1981
1957
  }
1982
1958
  else {
1983
- // Flair already initialized — resolve admin pass from env or running instance
1959
+ // Flair already initialized — resolve admin pass from env, falling back to
1960
+ // the persisted ~/.flair/admin-pass written at first install. Agent
1961
+ // seeding now goes through the ops API (#499), which always requires Basic
1962
+ // admin auth (it does NOT honor authorizeLocal), so a re-run of
1963
+ // `flair install --agent <new-id>` against an already-initialized local
1964
+ // Flair needs a real pass even when no env var is set.
1984
1965
  adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
1966
+ if (!adminPass) {
1967
+ try {
1968
+ const persisted = join(homedir(), ".flair", "admin-pass");
1969
+ if (existsSync(persisted))
1970
+ adminPass = readFileSync(persisted, "utf-8").trim();
1971
+ }
1972
+ catch { /* fall through with empty pass */ }
1973
+ }
1985
1974
  }
1986
1975
  const httpUrl = `http://127.0.0.1:${httpPort}`;
1987
1976
  // ── Step 2: Detect MCP clients ──
@@ -2040,17 +2029,16 @@ program
2040
2029
  writeFileSync(pubPath, Buffer.from(kp.publicKey));
2041
2030
  const pubKeyB64url = b64url(kp.publicKey);
2042
2031
  console.log(`Keypair written: ${privPath} ✓`);
2043
- // Seed agent - use REST API for local Harper, Ops API only when --ops-target is specified
2044
- if (opts.opsTarget) {
2045
- // Remote Harper via explicit ops target
2046
- console.log(`Seeding agent '${agentId}' via operations API (--ops-target)...`);
2047
- await seedAgentViaOpsApi(opts.opsTarget, agentId, pubKeyB64url, adminUser, adminPass);
2048
- }
2049
- else {
2050
- // Local Harper - use REST API
2051
- console.log(`Seeding agent '${agentId}' via REST API...`);
2052
- await seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass);
2053
- }
2032
+ // Seed agent via the Harper operations API. The Agent table is a REST
2033
+ // resource without a POST handler, so the insert body must go to the ops
2034
+ // API (POST <opsUrl>/ with {operation:"insert",...}), NOT the REST root
2035
+ // (which 405s the body as a collection POST to /Agent). This is the same
2036
+ // path `flair agent add` uses. (#499)
2037
+ const seedOpsTarget = opts.opsTarget ?? opsPort;
2038
+ console.log(opts.opsTarget
2039
+ ? `Seeding agent '${agentId}' via operations API (--ops-target)...`
2040
+ : `Seeding agent '${agentId}' via operations API...`);
2041
+ await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, adminUser, adminPass);
2054
2042
  console.log(`Agent '${agentId}' registered ✓`);
2055
2043
  }
2056
2044
  // ── Step 4: Wire MCP clients ──
@@ -2209,10 +2197,16 @@ agent
2209
2197
  if (adminPass) {
2210
2198
  const opsPort = resolveOpsPort(opts);
2211
2199
  const auth = Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64");
2200
+ // List every Agent without null-scanning the primary key. A
2201
+ // `starts_with ""` on `id` makes Harper search the index for nulls, which
2202
+ // the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
2203
+ // Use `createdAt > 1970-01-01` as the total "select all" predicate: every
2204
+ // Agent row has a non-null createdAt (schema: createdAt: String!), and its
2205
+ // index is built — same pattern as the `flair reembed` Memory scan. (#500)
2212
2206
  const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
2213
2207
  method: "POST",
2214
2208
  headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
2215
- body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Agent", search_attribute: "id", search_type: "starts_with", search_value: "", get_attributes: ["id", "name", "createdAt"] }),
2209
+ body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table: "Agent", operator: "and", conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }], get_attributes: ["id", "name", "createdAt"] }),
2216
2210
  });
2217
2211
  if (!res.ok) {
2218
2212
  const text = await res.text().catch(() => "");
@@ -4198,7 +4192,7 @@ rem
4198
4192
  console.log(result.prompt ?? "(no prompt returned)");
4199
4193
  console.log("--- End Prompt ---\n");
4200
4194
  console.log("Feed the prompt above to your LLM, then write insights back with:");
4201
- console.log(" flair memory add --agent <id> --content <insight> --durability persistent");
4195
+ console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
4202
4196
  }
4203
4197
  catch (err) {
4204
4198
  console.error(`Error: ${err.message}`);
@@ -7010,6 +7004,8 @@ memory.command("add [content]").requiredOption("--agent <id>")
7010
7004
  .option("--durability <d>", "standard").option("--tags <csv>")
7011
7005
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
7012
7006
  .option("--subject <text>", "one-line title / entity this memory is about")
7007
+ .option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
7008
+ .option("--visibility <value>", "Memory visibility (sets Memory.visibility). Use 'office' to share office-wide with every team agent; omit (default) to keep it private to this agent (flair#509)")
7013
7009
  .action(async (contentArg, opts) => {
7014
7010
  const content = contentArg ?? opts.content;
7015
7011
  if (!content) {
@@ -7026,6 +7022,11 @@ memory.command("add [content]").requiredOption("--agent <id>")
7026
7022
  body.summary = opts.summary;
7027
7023
  if (opts.subject)
7028
7024
  body.subject = opts.subject;
7025
+ if (opts.visibility)
7026
+ body.visibility = String(opts.visibility).trim();
7027
+ if (opts.derivedFrom) {
7028
+ body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
7029
+ }
7029
7030
  const out = await api("PUT", `/Memory/${memId}`, body);
7030
7031
  console.log(JSON.stringify(out, null, 2));
7031
7032
  });
@@ -7649,12 +7650,17 @@ soul.command("set")
7649
7650
  .option("--durability <d>", "permanent")
7650
7651
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
7651
7652
  .action(async (opts) => {
7652
- const out = await api("POST", "/Soul", {
7653
- id: `${opts.agent}:${opts.key}`,
7653
+ // PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
7654
+ // The Soul table resource has no POST handler, so a collection POST /Soul
7655
+ // 405s; the record must be written by its primary key. (#498)
7656
+ const id = `${opts.agent}:${opts.key}`;
7657
+ const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
7658
+ id,
7654
7659
  agentId: opts.agent,
7655
7660
  key: opts.key,
7656
7661
  value: opts.value,
7657
7662
  durability: opts.durability,
7663
+ createdAt: new Date().toISOString(),
7658
7664
  });
7659
7665
  const mode = render.resolveOutputMode(opts);
7660
7666
  if (mode === "json") {
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
2
2
  import { access, readFile, readdir } from "node:fs/promises";
3
3
  import { constants } from "node:fs";
4
4
  import { basename, extname, join } from "node:path";
5
+ import { localBaseUrl, resolvePublicBaseUrl } from "./a2a-url.js";
5
6
  const BEADS_ROOT = join(process.env.HOME || "/root", "ops", ".beads");
6
7
  const BEADS_ISSUES_DIR = join(BEADS_ROOT, "issues");
7
8
  const BEADS_ISSUES_JSONL = join(BEADS_ROOT, "issues.jsonl");
@@ -277,7 +278,13 @@ export class A2AAdapter extends Resource {
277
278
  allowRead() { return true; }
278
279
  allowCreate() { return true; }
279
280
  async get() {
280
- const host = process.env.FLAIR_PUBLIC_URL || "http://localhost:9926";
281
+ // Resolve the URL remote peers should use to reach this Flair. Prefer the
282
+ // request Host header (how the caller actually reached us) over a
283
+ // hardcoded port, so a default local install advertises the REAL HTTP
284
+ // port (19926), not the dead legacy 9926 (flair#507).
285
+ const ctx = this.getContext?.() ?? {};
286
+ const ctxRequest = ctx.request ?? ctx;
287
+ const host = resolvePublicBaseUrl(ctxRequest);
281
288
  return new Response(JSON.stringify({
282
289
  name: "TPS Agent Team",
283
290
  description: "TPS — agent OS for humans and AI agents. Coordinates via Flair.",
@@ -367,7 +374,7 @@ export class A2AAdapter extends Resource {
367
374
  closeStream();
368
375
  return;
369
376
  }
370
- const catchupUrl = `http://localhost:9926/OrgEventCatchup/${encodeURIComponent(agentId)}?since=${lastSeen}`;
377
+ const catchupUrl = `${localBaseUrl()}/OrgEventCatchup/${encodeURIComponent(agentId)}?since=${lastSeen}`;
371
378
  let events = [];
372
379
  try {
373
380
  const response = await fetch(catchupUrl);
@@ -17,44 +17,7 @@
17
17
  */
18
18
  import { Resource, databases } from "@harperfast/harper";
19
19
  import { isAdmin, allowVerified } from "./agent-auth.js";
20
- function parseDuration(s) {
21
- const m = s.match(/^(\d+)([dhm])$/);
22
- if (!m)
23
- return 30 * 86400_000;
24
- const n = Number(m[1]);
25
- if (m[2] === "d")
26
- return n * 86400_000;
27
- if (m[2] === "h")
28
- return n * 3600_000;
29
- if (m[2] === "m")
30
- return n * 60_000;
31
- return 30 * 86400_000;
32
- }
33
- function evaluate(record, now, olderThanMs) {
34
- const ageMs = record.createdAt ? now - new Date(record.createdAt).getTime() : 0;
35
- const count = record.retrievalCount ?? 0;
36
- const daysSinceRetrieved = record.lastRetrieved
37
- ? (now - new Date(record.lastRetrieved).getTime()) / 86400_000
38
- : Infinity;
39
- const { embedding, ...memory } = record;
40
- // Promote: high retrieval + persistent durability
41
- if (record.durability === "persistent" && count >= 5) {
42
- return { memory, suggestion: "promote", reason: `Retrieved ${count} times — strong promotion candidate for permanent` };
43
- }
44
- // Promote: standard → persistent if retrieved frequently
45
- if (record.durability === "standard" && count >= 3 && ageMs > 7 * 86400_000) {
46
- return { memory, suggestion: "promote", reason: `Retrieved ${count} times over ${Math.round(ageMs / 86400_000)} days — worth persisting` };
47
- }
48
- // Archive: old + never retrieved
49
- if (daysSinceRetrieved > 30 && count === 0 && ageMs > olderThanMs) {
50
- return { memory, suggestion: "archive", reason: `Never retrieved, ${Math.round(ageMs / 86400_000)} days old` };
51
- }
52
- // Archive: last retrieved > 60 days
53
- if (daysSinceRetrieved > 60 && count < 2) {
54
- return { memory, suggestion: "archive", reason: `Not retrieved in ${Math.round(daysSinceRetrieved)} days (only ${count} total retrievals)` };
55
- }
56
- return { memory, suggestion: "keep", reason: `Retrieved ${count} times, ${Math.round(daysSinceRetrieved)} days since last retrieval` };
57
- }
20
+ import { evaluate, parseDuration } from "./memory-consolidate-lib.js";
58
21
  export class ConsolidateMemories extends Resource {
59
22
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
60
23
  // admin elevation). Any verified agent may consolidate; the isAdmin checks in
@@ -4,40 +4,10 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
4
4
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
6
  import { wrapUntrusted } from "./content-safety.js";
7
- // ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
8
- const DURABILITY_WEIGHTS = {
9
- permanent: 1.0,
10
- persistent: 0.9,
11
- standard: 0.7,
12
- ephemeral: 0.4,
13
- };
14
- // Half-life in days for exponential decay per durability level
15
- const DECAY_HALF_LIFE_DAYS = {
16
- permanent: Infinity, // never decays
17
- persistent: 90,
18
- standard: 30,
19
- ephemeral: 7,
20
- };
21
- function recencyFactor(createdAt, durability) {
22
- const halfLife = DECAY_HALF_LIFE_DAYS[durability] ?? 30;
23
- if (halfLife === Infinity)
24
- return 1.0;
25
- const ageDays = (Date.now() - Date.parse(createdAt)) / (1000 * 60 * 60 * 24);
26
- const lambda = Math.LN2 / halfLife;
27
- return Math.exp(-lambda * ageDays);
28
- }
29
- function retrievalBoost(retrievalCount) {
30
- if (!retrievalCount || retrievalCount <= 0)
31
- return 1.0;
32
- return 1.0 + 0.1 * Math.log2(retrievalCount); // gentle boost: 10 retrievals → ~1.33x
33
- }
34
- function compositeScore(semanticScore, record) {
35
- const durability = record.durability ?? "standard";
36
- const dWeight = DURABILITY_WEIGHTS[durability] ?? 0.7;
37
- const rFactor = record.createdAt ? recencyFactor(record.createdAt, durability) : 1.0;
38
- const rBoost = retrievalBoost(record.retrievalCount ?? 0);
39
- return semanticScore * dWeight * rFactor * rBoost;
40
- }
7
+ // Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
8
+ // relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
9
+ // unit-tested directly (see test/unit/temporal-scoring.test.ts).
10
+ import { compositeScore } from "./scoring.js";
41
11
  // Convert HNSW cosine distance (1 - similarity) to similarity score
42
12
  function distanceToSimilarity(distance) {
43
13
  return 1 - distance;
@@ -0,0 +1,53 @@
1
+ // URL resolution for the A2A adapter — flair#507.
2
+ //
3
+ // A default local Flair install listens on DEFAULT_HTTP_PORT (19926, the
4
+ // CLI's `DEFAULT_PORT` in src/cli.ts), NOT on 9926 (the legacy early-install
5
+ // port). The A2A agent-card `url` and the streaming catch-up self-fetch must
6
+ // reflect the REAL listening port, or a remote A2A peer that follows discovery
7
+ // hits a dead port.
8
+ //
9
+ // Kept free of any @harperfast/harper import so the resolution logic is
10
+ // unit-testable without spinning up Harper (mirrors agentcard-fields.ts —
11
+ // avoids the simulator-pattern drift that let the AdminInstance predicate be
12
+ // reproduced-not-imported).
13
+ // The CLI's DEFAULT_HTTP_PORT (src/cli.ts `DEFAULT_PORT`). Keep in sync.
14
+ export const DEFAULT_HTTP_PORT = 19926;
15
+ // Loopback base URL for in-process self-calls (e.g. the streaming catch-up
16
+ // fetch). Points at the port Flair is ACTUALLY listening on, which Harper
17
+ // exposes via HTTP_PORT in the runtime env — never the public/proxy URL.
18
+ // Falls back to DEFAULT_HTTP_PORT for a default local install.
19
+ export function localBaseUrl(env = process.env) {
20
+ return `http://127.0.0.1:${env.HTTP_PORT || DEFAULT_HTTP_PORT}`;
21
+ }
22
+ // Public base URL advertised in the A2A agent card so remote peers can reach
23
+ // this Flair. Mirrors AdminInstance.resolvePublicUrl (flair#404):
24
+ // 1. FLAIR_PUBLIC_URL (explicit override, always wins)
25
+ // 2. Request headers (X-Forwarded-Proto/Host or Host) — how the caller
26
+ // actually reached us
27
+ // 3. http://127.0.0.1:${HTTP_PORT} — local-only fallback on the REAL port
28
+ export function resolvePublicBaseUrl(request, env = process.env) {
29
+ if (env.FLAIR_PUBLIC_URL) {
30
+ return env.FLAIR_PUBLIC_URL.replace(/\/$/, "");
31
+ }
32
+ const getHeader = (name) => {
33
+ const h = request?.headers;
34
+ if (!h)
35
+ return undefined;
36
+ if (typeof h.get === "function")
37
+ return h.get(name) ?? h.get(name.toLowerCase()) ?? undefined;
38
+ if (typeof h === "object") {
39
+ const obj = h.asObject ?? h;
40
+ return obj[name] ?? obj[name.toLowerCase()] ?? undefined;
41
+ }
42
+ return undefined;
43
+ };
44
+ const fwdProto = getHeader("X-Forwarded-Proto");
45
+ const fwdHost = getHeader("X-Forwarded-Host");
46
+ const host = fwdHost ?? getHeader("Host");
47
+ if (host && /^[\w.\-:]+$/.test(host)) {
48
+ const scheme = fwdProto && (fwdProto === "http" || fwdProto === "https") ? fwdProto : "https";
49
+ const effectiveScheme = fwdProto ? scheme : (host.includes(":") ? "http" : scheme);
50
+ return `${effectiveScheme}://${host}`;
51
+ }
52
+ return localBaseUrl(env);
53
+ }
@@ -0,0 +1,72 @@
1
+ // ─── Memory Consolidation — pure evaluation logic ───────────────────────────
2
+ // Pure helpers extracted from MemoryConsolidate.ts (Flair #502) so they can be
3
+ // unit-tested directly. Importing MemoryConsolidate.ts pulls in the Harper
4
+ // runtime (`databases` / `Resource`, storage init) and can't run outside a live
5
+ // Harper; this module has no Harper dependency, so
6
+ // test/unit/memory-consolidate.test.ts exercises the real shipped code.
7
+ export function parseDuration(s) {
8
+ const m = s.match(/^(\d+)([dhm])$/);
9
+ if (!m)
10
+ return 30 * 86400_000;
11
+ const n = Number(m[1]);
12
+ if (m[2] === "d")
13
+ return n * 86400_000;
14
+ if (m[2] === "h")
15
+ return n * 3600_000;
16
+ if (m[2] === "m")
17
+ return n * 60_000;
18
+ return 30 * 86400_000;
19
+ }
20
+ /**
21
+ * Classify a single memory record as a promote/archive/keep candidate.
22
+ *
23
+ * Idle age is `now - (lastRetrieved ?? createdAt)`: a memory that was just
24
+ * written and never read has lastRetrieved=null, so without the createdAt
25
+ * fallback its idle age would be Infinity and a minutes-old memory would read
26
+ * as maximally stale and be archived (Flair #502). The createdAt fallback
27
+ * measures "never retrieved" from when the memory was born.
28
+ *
29
+ * A grace window (olderThanMs — the maintenance olderThan) guarantees a
30
+ * freshly-created memory is never an archive candidate regardless of retrieval
31
+ * count: archival only considers memories that have had a fair chance to be read.
32
+ */
33
+ export function evaluate(record, now, olderThanMs) {
34
+ const ageMs = record.createdAt ? now - new Date(record.createdAt).getTime() : 0;
35
+ const count = record.retrievalCount ?? 0;
36
+ const lastUse = record.lastRetrieved ?? record.createdAt;
37
+ const idleMs = lastUse ? now - new Date(lastUse).getTime() : 0;
38
+ const daysIdle = idleMs / 86400_000;
39
+ const everRetrieved = record.lastRetrieved != null;
40
+ const { embedding, ...memory } = record;
41
+ // Promote: high retrieval + persistent durability
42
+ if (record.durability === "persistent" && count >= 5) {
43
+ return { memory, suggestion: "promote", reason: `Retrieved ${count} times — strong promotion candidate for permanent` };
44
+ }
45
+ // Promote: standard → persistent if retrieved frequently
46
+ if (record.durability === "standard" && count >= 3 && ageMs > 7 * 86400_000) {
47
+ return { memory, suggestion: "promote", reason: `Retrieved ${count} times over ${Math.round(ageMs / 86400_000)} days — worth persisting` };
48
+ }
49
+ // Grace window: a freshly-created memory is never an archive candidate,
50
+ // regardless of retrieval count (Flair #502). Archive branches below are
51
+ // reachable only for memories older than the maintenance window.
52
+ if (ageMs <= olderThanMs) {
53
+ return { memory, suggestion: "keep", reason: everRetrieved
54
+ ? `Retrieved ${count} times, ${Math.round(daysIdle)} days since last retrieval`
55
+ : `Created ${Math.round(ageMs / 86400_000)} days ago, not yet retrieved (within grace window)` };
56
+ }
57
+ // Archive: old + never retrieved
58
+ if (daysIdle > 30 && count === 0) {
59
+ return { memory, suggestion: "archive", reason: everRetrieved
60
+ ? `Not retrieved in ${Math.round(daysIdle)} days, ${Math.round(ageMs / 86400_000)} days old`
61
+ : `Never retrieved, ${Math.round(ageMs / 86400_000)} days old` };
62
+ }
63
+ // Archive: idle > 60 days with few retrievals
64
+ if (daysIdle > 60 && count < 2) {
65
+ return { memory, suggestion: "archive", reason: everRetrieved
66
+ ? `Not retrieved in ${Math.round(daysIdle)} days (only ${count} total retrievals)`
67
+ : `Never retrieved, ${Math.round(ageMs / 86400_000)} days old (0 total retrievals)` };
68
+ }
69
+ return { memory, suggestion: "keep", reason: everRetrieved
70
+ ? `Retrieved ${count} times, ${Math.round(daysIdle)} days since last retrieval`
71
+ : `Created ${Math.round(ageMs / 86400_000)} days ago, not yet retrieved` };
72
+ }
@@ -0,0 +1,52 @@
1
+ // ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
2
+ // Pure scoring functions extracted from SemanticSearch.ts (OPS-AYGD) so they can
3
+ // be unit-tested directly. Importing SemanticSearch.ts pulls in the Harper runtime
4
+ // (storage init) and can't run outside a live Harper; this module has no Harper
5
+ // dependency, so test/unit/temporal-scoring.test.ts exercises the real shipped code.
6
+ export const DURABILITY_WEIGHTS = {
7
+ permanent: 1.0,
8
+ persistent: 0.9,
9
+ standard: 0.7,
10
+ ephemeral: 0.4,
11
+ };
12
+ // Half-life in days for exponential decay per durability level
13
+ export const DECAY_HALF_LIFE_DAYS = {
14
+ permanent: Infinity, // never decays
15
+ persistent: 90,
16
+ standard: 30,
17
+ ephemeral: 7,
18
+ };
19
+ export function recencyFactor(createdAt, durability) {
20
+ const halfLife = DECAY_HALF_LIFE_DAYS[durability] ?? 30;
21
+ if (halfLife === Infinity)
22
+ return 1.0;
23
+ const ageDays = (Date.now() - Date.parse(createdAt)) / (1000 * 60 * 60 * 24);
24
+ const lambda = Math.LN2 / halfLife;
25
+ return Math.exp(-lambda * ageDays);
26
+ }
27
+ // OPS-AYGD: the retrieval boost was unbounded and OVERRODE semantic ranking —
28
+ // recall-eval 2026-06-19 showed composite p@3 0.83 vs raw 1.00, with a popular doc
29
+ // magnetised into 5/6 queries (its rBoost lifted a 0.55-0.65 semantic score above
30
+ // the correct docs). Bounded to a gentle nudge that breaks near-ties without
31
+ // overriding a clear semantic winner. Tuned offline against the real corpus
32
+ // (floor 0.5 + cap 1.1 → composite p@3 recovers to 1.00, magnet eliminated).
33
+ // A query-relative tie-breaker gate (boost only within ~0.05 of the top raw score)
34
+ // is the principled follow-up for graduated boosting; it needs the search loop to
35
+ // pass the candidate-set top score, so it's deferred to its own change.
36
+ export const RBOOST_CAP = 1.1; // max +10% — a tie-breaker, not an override
37
+ export const RBOOST_RELEVANCE_FLOOR = 0.5; // no boost at all for clearly-irrelevant docs
38
+ export function retrievalBoost(retrievalCount) {
39
+ if (!retrievalCount || retrievalCount <= 0)
40
+ return 1.0;
41
+ return Math.min(1.0 + 0.1 * Math.log2(retrievalCount), RBOOST_CAP); // gentle, capped
42
+ }
43
+ export function compositeScore(semanticScore, record) {
44
+ const durability = record.durability ?? "standard";
45
+ const dWeight = DURABILITY_WEIGHTS[durability] ?? 0.7;
46
+ const rFactor = record.createdAt ? recencyFactor(record.createdAt, durability) : 1.0;
47
+ // OPS-AYGD: only apply the retrieval boost when the record is genuinely relevant to
48
+ // this query (semanticScore clears the floor). Below the floor, a popular doc gets
49
+ // no lift — kills the cross-query magnet while preserving boosts for relevant docs.
50
+ const rBoost = semanticScore >= RBOOST_RELEVANCE_FLOOR ? retrievalBoost(record.retrievalCount ?? 0) : 1.0;
51
+ return semanticScore * dWeight * rFactor * rBoost;
52
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",