@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 (unauthenticated just { ok: true })
1787
+ // 1. Basic health check — try unauthenticated first, then with admin auth
1596
1788
  try {
1597
- const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
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 */ }
@@ -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) with pathname, id, etc.
45
- // Table.search() reads `target.conditions` from it. We inject our scope condition there.
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 = query.conditions ?? [];
48
- query.conditions = Array.isArray(existing)
49
- ? [agentIdCondition, ...existing]
50
- : [agentIdCondition, existing];
51
- return super.search(query);
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
- for await (const record of databases.flair.Memory.search(query)) {
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
- for await (const record of databases.flair.Memory.search(query)) {
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
- // ── RULE ──────────────────────────────────────────────────────────────────────
32
- // Never call `tables.X.put(partial)` directly anywhere in Flair resources.
33
- // Harper put() = FULL RECORD REPLACEMENT. Missing fields are deleted permanently.
34
- // Always use patchRecord() or patchRecordSilent().
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.0",
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
- }