@tpsdev-ai/flair 0.5.0 → 0.5.2
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
|
@@ -1579,6 +1579,198 @@ federation
|
|
|
1579
1579
|
process.exit(1);
|
|
1580
1580
|
}
|
|
1581
1581
|
});
|
|
1582
|
+
// ─── flair rem ───────────────────────────────────────────────────────────────
|
|
1583
|
+
// Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
|
|
1584
|
+
const rem = program.command("rem").description("Memory hygiene and reflection");
|
|
1585
|
+
rem
|
|
1586
|
+
.command("light")
|
|
1587
|
+
.description("NREM — quick cleanup: delete expired, archive old, consolidate candidates")
|
|
1588
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1589
|
+
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
1590
|
+
.option("--dry-run", "Preview changes without applying them")
|
|
1591
|
+
.action(async (opts) => {
|
|
1592
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1593
|
+
const dryRun = !!opts.dryRun;
|
|
1594
|
+
console.log(`\n-- rem light${dryRun ? " (dry run)" : ""} --`);
|
|
1595
|
+
if (agentId)
|
|
1596
|
+
console.log(`Agent: ${agentId}`);
|
|
1597
|
+
try {
|
|
1598
|
+
// Step 1: Maintenance — expire + archive
|
|
1599
|
+
const maint = await api("POST", "/MemoryMaintenance", {
|
|
1600
|
+
...(agentId ? { agentId } : {}),
|
|
1601
|
+
dryRun,
|
|
1602
|
+
});
|
|
1603
|
+
if (maint.error) {
|
|
1604
|
+
console.error(`Maintenance error: ${maint.error}`);
|
|
1605
|
+
process.exit(1);
|
|
1606
|
+
}
|
|
1607
|
+
const s = maint.stats ?? {};
|
|
1608
|
+
console.log("\nCleanup");
|
|
1609
|
+
console.log(` Expired (deleted): ${s.expired ?? 0}`);
|
|
1610
|
+
console.log(` Archived (soft): ${s.archived ?? 0}`);
|
|
1611
|
+
console.log(` Total scanned: ${s.total ?? 0}`);
|
|
1612
|
+
if (s.errors)
|
|
1613
|
+
console.log(` Errors: ${s.errors}`);
|
|
1614
|
+
// Step 2: Consolidation candidates
|
|
1615
|
+
if (!agentId) {
|
|
1616
|
+
console.log("\nConsolidation skipped — no agent ID (pass --agent or set FLAIR_AGENT_ID)");
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const consol = await api("POST", "/ConsolidateMemories", {
|
|
1620
|
+
agentId,
|
|
1621
|
+
scope: "all",
|
|
1622
|
+
});
|
|
1623
|
+
if (consol.error) {
|
|
1624
|
+
console.error(`Consolidation error: ${consol.error}`);
|
|
1625
|
+
process.exit(1);
|
|
1626
|
+
}
|
|
1627
|
+
const candidates = consol.candidates ?? [];
|
|
1628
|
+
const promote = candidates.filter((c) => c.suggestion === "promote");
|
|
1629
|
+
const archive = candidates.filter((c) => c.suggestion === "archive");
|
|
1630
|
+
console.log("\nConsolidation candidates");
|
|
1631
|
+
console.log(` Promote: ${promote.length}`);
|
|
1632
|
+
console.log(` Archive: ${archive.length}`);
|
|
1633
|
+
if (promote.length > 0) {
|
|
1634
|
+
console.log("\n Promote:");
|
|
1635
|
+
for (const c of promote) {
|
|
1636
|
+
console.log(` [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (archive.length > 0) {
|
|
1640
|
+
console.log("\n Archive:");
|
|
1641
|
+
for (const c of archive) {
|
|
1642
|
+
console.log(` [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
console.log(`\nDone.${dryRun ? " No changes applied (dry run)." : ""}`);
|
|
1646
|
+
}
|
|
1647
|
+
catch (err) {
|
|
1648
|
+
console.error(`Error: ${err.message}`);
|
|
1649
|
+
process.exit(1);
|
|
1650
|
+
}
|
|
1651
|
+
});
|
|
1652
|
+
rem
|
|
1653
|
+
.command("rapid")
|
|
1654
|
+
.description("REM — reflection/learning: generate a structured LLM reflection prompt")
|
|
1655
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1656
|
+
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
1657
|
+
.option("--focus <type>", "lessons_learned | patterns | decisions | errors", "lessons_learned")
|
|
1658
|
+
.option("--since <date>", "ISO timestamp lower bound (default: 24h ago)")
|
|
1659
|
+
.action(async (opts) => {
|
|
1660
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1661
|
+
if (!agentId) {
|
|
1662
|
+
console.error("Error: --agent <id> or FLAIR_AGENT_ID env required");
|
|
1663
|
+
process.exit(1);
|
|
1664
|
+
}
|
|
1665
|
+
console.log(`\n-- rem rapid --`);
|
|
1666
|
+
console.log(`Agent: ${agentId} Focus: ${opts.focus}`);
|
|
1667
|
+
try {
|
|
1668
|
+
const body = {
|
|
1669
|
+
agentId,
|
|
1670
|
+
focus: opts.focus,
|
|
1671
|
+
};
|
|
1672
|
+
if (opts.since)
|
|
1673
|
+
body.since = opts.since;
|
|
1674
|
+
const result = await api("POST", "/ReflectMemories", body);
|
|
1675
|
+
if (result.error) {
|
|
1676
|
+
console.error(`Reflection error: ${result.error}`);
|
|
1677
|
+
process.exit(1);
|
|
1678
|
+
}
|
|
1679
|
+
console.log(`\nSource memories: ${result.count ?? 0}`);
|
|
1680
|
+
if (result.suggestedTags?.length) {
|
|
1681
|
+
console.log(`Tags: ${result.suggestedTags.join(", ")}`);
|
|
1682
|
+
}
|
|
1683
|
+
console.log("\n--- Reflection Prompt ---");
|
|
1684
|
+
console.log(result.prompt ?? "(no prompt returned)");
|
|
1685
|
+
console.log("--- End Prompt ---\n");
|
|
1686
|
+
console.log("Feed the prompt above to your LLM, then write insights back with:");
|
|
1687
|
+
console.log(" flair memory add --agent <id> --content <insight> --durability persistent");
|
|
1688
|
+
}
|
|
1689
|
+
catch (err) {
|
|
1690
|
+
console.error(`Error: ${err.message}`);
|
|
1691
|
+
process.exit(1);
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
rem
|
|
1695
|
+
.command("restorative")
|
|
1696
|
+
.description("Deep audit: full maintenance + consolidation (olderThan=7d) + reflection")
|
|
1697
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1698
|
+
.option("--agent <id>", "Agent ID (or FLAIR_AGENT_ID env)")
|
|
1699
|
+
.option("--dry-run", "Preview maintenance changes without applying them")
|
|
1700
|
+
.action(async (opts) => {
|
|
1701
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1702
|
+
const dryRun = !!opts.dryRun;
|
|
1703
|
+
console.log(`\n== rem restorative${dryRun ? " (dry run)" : ""} ==`);
|
|
1704
|
+
if (agentId)
|
|
1705
|
+
console.log(`Agent: ${agentId}`);
|
|
1706
|
+
try {
|
|
1707
|
+
// Step 1: Maintenance
|
|
1708
|
+
console.log("\n[1/3] Maintenance...");
|
|
1709
|
+
const maint = await api("POST", "/MemoryMaintenance", {
|
|
1710
|
+
...(agentId ? { agentId } : {}),
|
|
1711
|
+
dryRun,
|
|
1712
|
+
});
|
|
1713
|
+
if (maint.error) {
|
|
1714
|
+
console.error(`Maintenance error: ${maint.error}`);
|
|
1715
|
+
process.exit(1);
|
|
1716
|
+
}
|
|
1717
|
+
const s = maint.stats ?? {};
|
|
1718
|
+
console.log(` Expired: ${s.expired ?? 0} Archived: ${s.archived ?? 0} Scanned: ${s.total ?? 0}${s.errors ? ` Errors: ${s.errors}` : ""}`);
|
|
1719
|
+
// Step 2: Consolidation (skip if no agentId)
|
|
1720
|
+
if (agentId) {
|
|
1721
|
+
console.log("\n[2/3] Consolidation (scope=all, olderThan=7d)...");
|
|
1722
|
+
const consol = await api("POST", "/ConsolidateMemories", {
|
|
1723
|
+
agentId,
|
|
1724
|
+
scope: "all",
|
|
1725
|
+
olderThan: "7d",
|
|
1726
|
+
});
|
|
1727
|
+
if (consol.error) {
|
|
1728
|
+
console.error(`Consolidation error: ${consol.error}`);
|
|
1729
|
+
process.exit(1);
|
|
1730
|
+
}
|
|
1731
|
+
const candidates = consol.candidates ?? [];
|
|
1732
|
+
const promote = candidates.filter((c) => c.suggestion === "promote");
|
|
1733
|
+
const archive = candidates.filter((c) => c.suggestion === "archive");
|
|
1734
|
+
console.log(` Promote candidates: ${promote.length} Archive candidates: ${archive.length}`);
|
|
1735
|
+
for (const c of promote) {
|
|
1736
|
+
console.log(` promote [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1737
|
+
}
|
|
1738
|
+
for (const c of archive) {
|
|
1739
|
+
console.log(` archive [${c.memory?.id ?? "?"}] ${c.reason}`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
else {
|
|
1743
|
+
console.log("\n[2/3] Consolidation skipped — no agent ID");
|
|
1744
|
+
}
|
|
1745
|
+
// Step 3: Reflection
|
|
1746
|
+
if (agentId) {
|
|
1747
|
+
console.log("\n[3/3] Reflection (scope=all)...");
|
|
1748
|
+
const reflect = await api("POST", "/ReflectMemories", {
|
|
1749
|
+
agentId,
|
|
1750
|
+
scope: "all",
|
|
1751
|
+
});
|
|
1752
|
+
if (reflect.error) {
|
|
1753
|
+
console.error(`Reflection error: ${reflect.error}`);
|
|
1754
|
+
process.exit(1);
|
|
1755
|
+
}
|
|
1756
|
+
console.log(` Source memories: ${reflect.count ?? 0}`);
|
|
1757
|
+
if (reflect.suggestedTags?.length) {
|
|
1758
|
+
console.log(` Tags: ${reflect.suggestedTags.join(", ")}`);
|
|
1759
|
+
}
|
|
1760
|
+
console.log("\n--- Reflection Prompt ---");
|
|
1761
|
+
console.log(reflect.prompt ?? "(no prompt returned)");
|
|
1762
|
+
console.log("--- End Prompt ---");
|
|
1763
|
+
}
|
|
1764
|
+
else {
|
|
1765
|
+
console.log("\n[3/3] Reflection skipped — no agent ID");
|
|
1766
|
+
}
|
|
1767
|
+
console.log(`\nRestorative cycle complete.${dryRun ? " No changes applied (dry run)." : ""}`);
|
|
1768
|
+
}
|
|
1769
|
+
catch (err) {
|
|
1770
|
+
console.error(`Error: ${err.message}`);
|
|
1771
|
+
process.exit(1);
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1582
1774
|
// ─── flair status ─────────────────────────────────────────────────────────────
|
|
1583
1775
|
program
|
|
1584
1776
|
.command("status")
|
|
@@ -1592,9 +1784,19 @@ program
|
|
|
1592
1784
|
const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
|
|
1593
1785
|
let healthy = false;
|
|
1594
1786
|
let healthData = null;
|
|
1595
|
-
// 1. Basic health check
|
|
1787
|
+
// 1. Basic health check — try unauthenticated first, then with admin auth
|
|
1596
1788
|
try {
|
|
1597
|
-
|
|
1789
|
+
let res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
1790
|
+
if (!res.ok && res.status === 401) {
|
|
1791
|
+
// Harper requires auth (authorizeLocal: true) — retry with admin credentials
|
|
1792
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
1793
|
+
if (adminPass) {
|
|
1794
|
+
res = await fetch(`${baseUrl}/Health`, {
|
|
1795
|
+
headers: { Authorization: `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}` },
|
|
1796
|
+
signal: AbortSignal.timeout(5000),
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1598
1800
|
healthy = res.ok;
|
|
1599
1801
|
}
|
|
1600
1802
|
catch { /* unreachable */ }
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { patchRecord } from "./table-helpers.js";
|
|
2
|
+
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
3
|
import { isAdmin } from "./auth-middleware.js";
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
5
|
import { scanContent, isStrictMode } from "./content-safety.js";
|
|
@@ -41,20 +41,31 @@ export class Memory extends databases.flair.Memory {
|
|
|
41
41
|
const agentIdCondition = allowedOwners.length === 1
|
|
42
42
|
? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
|
|
43
43
|
: { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
|
|
44
|
-
// Harper passes `query` as a RequestTarget (extends URLSearchParams)
|
|
45
|
-
//
|
|
44
|
+
// Harper passes `query` as a RequestTarget (extends URLSearchParams). Table.search()
|
|
45
|
+
// reads `target.conditions`; when that is unset, it iterates URLSearchParams entries
|
|
46
|
+
// as conditions instead. We inject our scope condition into `.conditions`, which
|
|
47
|
+
// means URL params (except agentId, which we always enforce) must also be translated
|
|
48
|
+
// to conditions or they'll be silently dropped.
|
|
46
49
|
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
47
|
-
const existing =
|
|
48
|
-
query.conditions
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const existing = [];
|
|
51
|
+
if (Array.isArray(query.conditions) && query.conditions.length > 0) {
|
|
52
|
+
existing.push(...query.conditions);
|
|
53
|
+
}
|
|
54
|
+
else if (typeof query.entries === "function") {
|
|
55
|
+
for (const [k, v] of query.entries()) {
|
|
56
|
+
if (k === "agentId")
|
|
57
|
+
continue;
|
|
58
|
+
existing.push({ attribute: k, comparator: "equals", value: v });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
query.conditions = [agentIdCondition, ...existing];
|
|
62
|
+
return withDetachedTxn(ctx, () => super.search(query));
|
|
52
63
|
}
|
|
53
64
|
// Fallback: plain array or no query (internal calls)
|
|
54
65
|
const conditions = Array.isArray(query) && query.length > 0
|
|
55
66
|
? [agentIdCondition, ...query]
|
|
56
67
|
: [agentIdCondition];
|
|
57
|
-
return super.search(conditions);
|
|
68
|
+
return withDetachedTxn(ctx, () => super.search(conditions));
|
|
58
69
|
}
|
|
59
70
|
async post(content, context) {
|
|
60
71
|
// Rate limiting — use authenticated agent ID, not client-supplied body field
|
|
@@ -128,6 +139,25 @@ export class Memory extends databases.flair.Memory {
|
|
|
128
139
|
return super.post(content);
|
|
129
140
|
}
|
|
130
141
|
async put(content) {
|
|
142
|
+
// Reindex migration bypass: admin-only escape hatch used by the
|
|
143
|
+
// MemoryReindex admin endpoint to re-PUT each existing record byte-for-byte
|
|
144
|
+
// (no updatedAt bump, no embedding regen, no safety rescan) so Harper
|
|
145
|
+
// repopulates secondary indices. Because this skips content safety and
|
|
146
|
+
// auditability, it must be gated to admins. Internal calls (no auth
|
|
147
|
+
// context) pass through, matching the pattern used in delete().
|
|
148
|
+
if (content._reindex === true) {
|
|
149
|
+
const ctx = this.getContext?.();
|
|
150
|
+
const request = ctx?.request ?? ctx;
|
|
151
|
+
const actorId = request?.tpsAgent;
|
|
152
|
+
if (actorId && !(await isAdmin(actorId))) {
|
|
153
|
+
return new Response(JSON.stringify({ error: "reindex_admin_only" }), {
|
|
154
|
+
status: 403,
|
|
155
|
+
headers: { "Content-Type": "application/json" },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
delete content._reindex;
|
|
159
|
+
return super.put(content);
|
|
160
|
+
}
|
|
131
161
|
const now = new Date().toISOString();
|
|
132
162
|
content.updatedAt = now;
|
|
133
163
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MemoryReindex.ts — One-shot migration: heal Harper secondary indices on Memory.
|
|
3
|
+
*
|
|
4
|
+
* POST /MemoryReindex — admin-only.
|
|
5
|
+
*
|
|
6
|
+
* Body:
|
|
7
|
+
* { dryRun?: boolean, agentId?: string, batchSize?: number }
|
|
8
|
+
*
|
|
9
|
+
* Behaviour:
|
|
10
|
+
* Scans the Memory primary store (unfiltered, via the base table class) and
|
|
11
|
+
* compares per-agent primary counts against per-agent secondary-index counts
|
|
12
|
+
* (an equals lookup on agentId). Any agent whose secondary count is below
|
|
13
|
+
* its primary count has unindexed records. Re-PUT every record with the
|
|
14
|
+
* _reindex escape hatch so Memory.put() preserves every field byte-for-byte
|
|
15
|
+
* (no updatedAt bump, no embedding regen, no safety rescan).
|
|
16
|
+
*
|
|
17
|
+
* Why this exists: Harper's background runIndexing() pass populates secondary
|
|
18
|
+
* indices only on schema changes that add a new indexed attribute. If that
|
|
19
|
+
* pass is interrupted, or if a schema version change doesn't re-register an
|
|
20
|
+
* existing index, older records live in the primary store but never make it
|
|
21
|
+
* into the secondary index. Scoped search() via RequestTarget.conditions —
|
|
22
|
+
* which uses the agentId index — then returns empty for those agents.
|
|
23
|
+
*
|
|
24
|
+
* This endpoint is idempotent: running it again finds zero drift.
|
|
25
|
+
*/
|
|
26
|
+
import { Resource, databases } from "@harperfast/harper";
|
|
27
|
+
import { isAdmin } from "./auth-middleware.js";
|
|
28
|
+
export class MemoryReindex extends Resource {
|
|
29
|
+
async post(data) {
|
|
30
|
+
const ctx = this.getContext?.();
|
|
31
|
+
const request = ctx?.request ?? ctx ?? this.request;
|
|
32
|
+
const authAgent = request?.tpsAgent;
|
|
33
|
+
const basicAgent = request?.headers?.get?.("x-tps-agent");
|
|
34
|
+
const isBasicAdmin = basicAgent === "admin";
|
|
35
|
+
const isEd25519Admin = Boolean(authAgent) && request?.tpsAgentIsAdmin === true;
|
|
36
|
+
if (!isBasicAdmin && !(isEd25519Admin && (await isAdmin(authAgent)))) {
|
|
37
|
+
return new Response(JSON.stringify({ error: "forbidden: admin required" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
38
|
+
}
|
|
39
|
+
const dryRun = data?.dryRun === true;
|
|
40
|
+
const agentFilter = typeof data?.agentId === "string" ? data.agentId : null;
|
|
41
|
+
const batchSize = Math.max(1, Math.min(Number(data?.batchSize) || 100, 1000));
|
|
42
|
+
const Memory = databases.flair.Memory;
|
|
43
|
+
const stats = {
|
|
44
|
+
scanned: 0,
|
|
45
|
+
agentsWithDrift: 0,
|
|
46
|
+
totalMissing: 0,
|
|
47
|
+
reindexed: 0,
|
|
48
|
+
errors: 0,
|
|
49
|
+
dryRun,
|
|
50
|
+
agentFilter,
|
|
51
|
+
drift: [],
|
|
52
|
+
errorSamples: [],
|
|
53
|
+
};
|
|
54
|
+
// Pass 1: primary-store scan. Count records per agent.
|
|
55
|
+
const primaryByAgent = new Map();
|
|
56
|
+
const recordsToReindex = [];
|
|
57
|
+
for await (const record of Memory.search()) {
|
|
58
|
+
if (agentFilter && record.agentId !== agentFilter)
|
|
59
|
+
continue;
|
|
60
|
+
if (!record.id || !record.agentId)
|
|
61
|
+
continue;
|
|
62
|
+
primaryByAgent.set(record.agentId, (primaryByAgent.get(record.agentId) ?? 0) + 1);
|
|
63
|
+
recordsToReindex.push(record.id);
|
|
64
|
+
}
|
|
65
|
+
stats.scanned = recordsToReindex.length;
|
|
66
|
+
// Pass 2: for each agent, probe the secondary index via an agentId-only equals
|
|
67
|
+
// lookup. Harper's query planner can't reorder a single condition, so this
|
|
68
|
+
// actually hits Table.indices.agentId. Drift = primary - indexed.
|
|
69
|
+
for (const [agentId, primary] of primaryByAgent) {
|
|
70
|
+
let indexed = 0;
|
|
71
|
+
try {
|
|
72
|
+
for await (const _ of Memory.search({
|
|
73
|
+
conditions: [{ attribute: "agentId", comparator: "equals", value: agentId }],
|
|
74
|
+
})) {
|
|
75
|
+
indexed++;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
stats.errors++;
|
|
80
|
+
if (stats.errorSamples.length < 5) {
|
|
81
|
+
stats.errorSamples.push({ id: agentId, message: err?.message ?? String(err) });
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const missing = primary - indexed;
|
|
86
|
+
if (missing > 0) {
|
|
87
|
+
stats.agentsWithDrift++;
|
|
88
|
+
stats.totalMissing += missing;
|
|
89
|
+
stats.drift.push({ agentId, primary, indexed, missing });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
stats.drift.sort((a, b) => b.missing - a.missing);
|
|
93
|
+
if (dryRun) {
|
|
94
|
+
return {
|
|
95
|
+
message: `dry run: ${stats.totalMissing} record${stats.totalMissing === 1 ? "" : "s"} missing from agentId index across ${stats.agentsWithDrift} agent${stats.agentsWithDrift === 1 ? "" : "s"}. Reindex will re-PUT all ${stats.scanned} records to heal.`,
|
|
96
|
+
stats,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Pass 3: re-PUT every primary-store record with _reindex=true so Memory.put()
|
|
100
|
+
// preserves every field byte-for-byte. The re-PUT forces Harper to re-insert
|
|
101
|
+
// into all secondary indices. Cheaper-than-sound variants (only re-PUT records
|
|
102
|
+
// that appear missing) would miss rows the primary scan sees fine but that
|
|
103
|
+
// Harper's read path can't locate via any index path.
|
|
104
|
+
for (let i = 0; i < recordsToReindex.length; i += batchSize) {
|
|
105
|
+
const chunk = recordsToReindex.slice(i, i + batchSize);
|
|
106
|
+
for (const id of chunk) {
|
|
107
|
+
try {
|
|
108
|
+
const record = await Memory.get(id);
|
|
109
|
+
if (!record) {
|
|
110
|
+
stats.errors++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
await Memory.put({ ...record, _reindex: true });
|
|
114
|
+
stats.reindexed++;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
stats.errors++;
|
|
118
|
+
if (stats.errorSamples.length < 5) {
|
|
119
|
+
stats.errorSamples.push({ id, message: err?.message ?? String(err) });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
message: `reindex complete: ${stats.reindexed} re-indexed, ${stats.errors} errors, ${stats.totalMissing} pre-existing index gap${stats.totalMissing === 1 ? "" : "s"} healed`,
|
|
126
|
+
stats,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { getEmbedding, getMode } from "./embeddings-provider.js";
|
|
3
|
-
import { patchRecord } from "./table-helpers.js";
|
|
3
|
+
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
4
4
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
5
5
|
import { wrapUntrusted } from "./content-safety.js";
|
|
6
6
|
// ─── Temporal Decay + Relevance Scoring ─────────────────────────────────────
|
|
@@ -178,7 +178,11 @@ export class SemanticSearch extends Resource {
|
|
|
178
178
|
if (conditions.length > 0) {
|
|
179
179
|
query.conditions = conditions;
|
|
180
180
|
}
|
|
181
|
-
|
|
181
|
+
// MemoryGrant.search above left a closed transaction in ctx's chain —
|
|
182
|
+
// detach it so Harper builds a fresh transaction for this Memory read.
|
|
183
|
+
const ctx = this.getContext?.();
|
|
184
|
+
const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
|
|
185
|
+
for await (const record of memoryResults) {
|
|
182
186
|
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
183
187
|
continue;
|
|
184
188
|
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
@@ -214,7 +218,11 @@ export class SemanticSearch extends Resource {
|
|
|
214
218
|
// Full scan is only used when there's no query embedding (e.g. tag-only
|
|
215
219
|
// or subject-only searches, or when the embedding engine is unavailable).
|
|
216
220
|
const query = conditions.length > 0 ? { conditions } : {};
|
|
217
|
-
|
|
221
|
+
// MemoryGrant.search above left a closed transaction in ctx's chain —
|
|
222
|
+
// detach it so Harper builds a fresh transaction for this Memory read.
|
|
223
|
+
const ctx = this.getContext?.();
|
|
224
|
+
const memoryResults = withDetachedTxn(ctx, () => databases.flair.Memory.search(query));
|
|
225
|
+
for await (const record of memoryResults) {
|
|
218
226
|
if (record.expiresAt && Date.parse(record.expiresAt) < Date.now())
|
|
219
227
|
continue;
|
|
220
228
|
if (sinceDate && record.createdAt && new Date(record.createdAt) < sinceDate)
|
|
@@ -28,8 +28,33 @@ export function patchRecordSilent(table, id, patch) {
|
|
|
28
28
|
// Harper put() = FULL RECORD REPLACEMENT. Missing fields are deleted permanently.
|
|
29
29
|
// Always use patchRecord() or patchRecordSilent().
|
|
30
30
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Detach ctx.transaction while `fn` runs, then restore.
|
|
33
|
+
*
|
|
34
|
+
* Why: when a request does two table reads in sequence (e.g. MemoryGrant.search
|
|
35
|
+
* then Memory.search), the first generator drains and leaves its transaction
|
|
36
|
+
* CLOSED at the tail of ctx.transaction's linked chain. Harper's txnForContext
|
|
37
|
+
* (Table.js ~3633) walks that chain when opening a transaction for the second
|
|
38
|
+
* table and inherits the CLOSED state onto the fresh transaction — which then
|
|
39
|
+
* silently reads zero rows.
|
|
40
|
+
*
|
|
41
|
+
* Clearing ctx.transaction forces Harper to build a brand-new ImmediateTransaction
|
|
42
|
+
* for the inner call. Table.search captures that transaction synchronously into
|
|
43
|
+
* its result generator's closure, so the saved chain can be restored immediately
|
|
44
|
+
* without affecting the streaming read.
|
|
45
|
+
*
|
|
46
|
+
* Use this whenever a resource method reads one table, then reads another in
|
|
47
|
+
* the same request context.
|
|
48
|
+
*/
|
|
49
|
+
export function withDetachedTxn(ctx, fn) {
|
|
50
|
+
if (!ctx)
|
|
51
|
+
return fn();
|
|
52
|
+
const saved = ctx.transaction;
|
|
53
|
+
ctx.transaction = undefined;
|
|
54
|
+
try {
|
|
55
|
+
return fn();
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
ctx.transaction = saved;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
|
@@ -36,10 +36,13 @@
|
|
|
36
36
|
"SECURITY.md"
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
40
|
+
"prebuild": "npm run clean",
|
|
39
41
|
"build": "tsc -p tsconfig.json --noCheck",
|
|
40
42
|
"build:cli": "tsc -p tsconfig.cli.json --noCheck",
|
|
41
43
|
"prepublishOnly": "npm run build && npm run build:cli",
|
|
42
44
|
"test": "bun test",
|
|
45
|
+
"test:e2e": "playwright test",
|
|
43
46
|
"release": "./scripts/release.sh"
|
|
44
47
|
},
|
|
45
48
|
"publishConfig": {
|
|
@@ -56,6 +59,7 @@
|
|
|
56
59
|
"tweetnacl": "1.0.3"
|
|
57
60
|
},
|
|
58
61
|
"devDependencies": {
|
|
62
|
+
"@playwright/test": "^1.59.1",
|
|
59
63
|
"@types/node": "24.11.0",
|
|
60
64
|
"bun-types": "1.3.11",
|
|
61
65
|
"typescript": "5.9.3"
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fallback hash-based embedding (used when sidecar is unavailable).
|
|
3
|
-
* Real embeddings come from the embed-server sidecar (harper-fabric-embeddings).
|
|
4
|
-
*/
|
|
5
|
-
const DIMS = 512;
|
|
6
|
-
function h1(s) { let h = 5381; for (let i = 0; i < s.length; i++)
|
|
7
|
-
h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h % DIMS; }
|
|
8
|
-
function h2(s) { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) {
|
|
9
|
-
h ^= s.charCodeAt(i);
|
|
10
|
-
h = Math.imul(h, 0x01000193) >>> 0;
|
|
11
|
-
} return h % DIMS; }
|
|
12
|
-
export function fallbackEmbed(text) {
|
|
13
|
-
const tokens = text.toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').split(/\s+/).filter(t => t.length > 1);
|
|
14
|
-
const clean = text.toLowerCase().replace(/\s+/g, ' ');
|
|
15
|
-
const vec = new Float64Array(DIMS);
|
|
16
|
-
for (const t of tokens) {
|
|
17
|
-
vec[h1(t)] += 2;
|
|
18
|
-
vec[h2(t)] += 1;
|
|
19
|
-
}
|
|
20
|
-
for (let i = 0; i < tokens.length - 1; i++)
|
|
21
|
-
vec[h1(tokens[i] + '_' + tokens[i + 1])] += 1.5;
|
|
22
|
-
for (let i = 0; i <= clean.length - 3; i++)
|
|
23
|
-
vec[h1(clean.slice(i, i + 3))] += 0.5;
|
|
24
|
-
for (let i = 0; i < DIMS; i++)
|
|
25
|
-
if (vec[i] > 0)
|
|
26
|
-
vec[i] = 1 + Math.log(vec[i]);
|
|
27
|
-
let norm = 0;
|
|
28
|
-
for (let i = 0; i < DIMS; i++)
|
|
29
|
-
norm += vec[i] * vec[i];
|
|
30
|
-
norm = Math.sqrt(norm);
|
|
31
|
-
if (norm > 0)
|
|
32
|
-
for (let i = 0; i < DIMS; i++)
|
|
33
|
-
vec[i] /= norm;
|
|
34
|
-
return Array.from(vec);
|
|
35
|
-
}
|
|
36
|
-
export function cosineSimilarity(a, b) {
|
|
37
|
-
let dot = 0;
|
|
38
|
-
const len = Math.min(a.length, b.length);
|
|
39
|
-
for (let i = 0; i < len; i++)
|
|
40
|
-
dot += a[i] * b[i];
|
|
41
|
-
return dot;
|
|
42
|
-
}
|