@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 +51 -45
- package/dist/resources/A2AAdapter.js +9 -2
- package/dist/resources/MemoryConsolidate.js +1 -38
- package/dist/resources/SemanticSearch.js +4 -34
- package/dist/resources/a2a-url.js +53 -0
- package/dist/resources/memory-consolidate-lib.js +72 -0
- package/dist/resources/scoring.js +52 -0
- package/package.json +1 -1
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
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
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
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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: "
|
|
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
|
-
|
|
7653
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
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",
|