@tpsdev-ai/flair 0.5.1 → 0.5.3
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
|
@@ -7,7 +7,28 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
|
|
9
9
|
import { keystore } from "./keystore.js";
|
|
10
|
-
|
|
10
|
+
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
11
|
+
// src/ into resources/, which don't survive npm packaging (see also
|
|
12
|
+
// resources/federation-crypto.ts; the two must stay in sync).
|
|
13
|
+
function sortKeys(val) {
|
|
14
|
+
if (val === null || val === undefined || typeof val !== "object")
|
|
15
|
+
return val;
|
|
16
|
+
if (Array.isArray(val))
|
|
17
|
+
return val.map(sortKeys);
|
|
18
|
+
const sorted = {};
|
|
19
|
+
for (const key of Object.keys(val).sort()) {
|
|
20
|
+
sorted[key] = sortKeys(val[key]);
|
|
21
|
+
}
|
|
22
|
+
return sorted;
|
|
23
|
+
}
|
|
24
|
+
function canonicalize(obj) {
|
|
25
|
+
return JSON.stringify(sortKeys(obj));
|
|
26
|
+
}
|
|
27
|
+
function signBody(body, secretKey) {
|
|
28
|
+
const message = new TextEncoder().encode(canonicalize(body));
|
|
29
|
+
const sig = nacl.sign.detached(message, secretKey);
|
|
30
|
+
return Buffer.from(sig).toString("base64url");
|
|
31
|
+
}
|
|
11
32
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
12
33
|
const DEFAULT_PORT = 19926;
|
|
13
34
|
const DEFAULT_OPS_PORT = 19925;
|
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.3",
|
|
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,6 +36,8 @@
|
|
|
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",
|
|
@@ -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
|
-
}
|