@tpsdev-ai/flair-client 0.21.0 → 0.22.1

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/README.md CHANGED
@@ -16,7 +16,7 @@ npm install @tpsdev-ai/flair-client
16
16
  import { FlairClient } from '@tpsdev-ai/flair-client'
17
17
 
18
18
  const flair = new FlairClient({
19
- url: 'http://localhost:9926', // or remote: https://flair.example.com
19
+ url: 'http://localhost:19926', // or remote: https://flair.example.com
20
20
  agentId: 'my-agent',
21
21
  // keyPath auto-resolved from ~/.flair/keys/my-agent.key
22
22
  })
@@ -108,7 +108,7 @@ Or use the Flair CLI directly:
108
108
 
109
109
  | Option | Env | Default | Description |
110
110
  |--------|-----|---------|-------------|
111
- | `url` | `FLAIR_URL` | `http://localhost:9926` | Flair server URL |
111
+ | `url` | `FLAIR_URL` | `http://localhost:19926` | Flair server URL |
112
112
  | `agentId` | `FLAIR_AGENT_ID` | — | Agent identifier |
113
113
  | `keyPath` | `FLAIR_KEY_DIR` | auto-resolved | Private key path |
114
114
  | `timeoutMs` | — | `10000` | Request timeout |
package/dist/client.d.ts CHANGED
@@ -7,11 +7,12 @@
7
7
  * const results = await flair.memory.search('that thing')
8
8
  * const ctx = await flair.bootstrap({ maxTokens: 4000 })
9
9
  */
10
- import type { FlairClientConfig, Memory, MemoryType, Durability, Visibility, SoulEntry, SearchResult, BootstrapResult } from "./types.js";
10
+ import type { FlairClientConfig, Memory, MemoryType, Durability, Visibility, SoulEntry, SearchResult, BootstrapResult, Relationship } from "./types.js";
11
11
  export declare class FlairClient {
12
12
  readonly url: string;
13
13
  readonly agentId: string;
14
14
  readonly memory: MemoryApi;
15
+ readonly relationship: RelationshipApi;
15
16
  readonly soul: SoulApi;
16
17
  private privateKey;
17
18
  private keyResolved;
@@ -128,6 +129,92 @@ declare class MemoryApi {
128
129
  /** Delete a memory. */
129
130
  delete(id: string): Promise<void>;
130
131
  }
132
+ /**
133
+ * Canonical, per-owner, deterministic Relationship id (relationship-write-path,
134
+ * K&S-approved refinement) — `base64url(SHA-256(lowercased agentId+subject+
135
+ * predicate+object))`, truncated to the first 16 bytes (22 base64url chars,
136
+ * 128-bit collision resistance). A REAL cryptographic hash on purpose
137
+ * (`crypto.createHash('sha256')`, NOT `Bun.hash` or any weak/platform-specific
138
+ * hash) — Sherlock: blocks intentional collision attacks; Kern: portable if
139
+ * flair ever runs on another runtime.
140
+ *
141
+ * Hashes ONLY the triple-identity fields (agentId + subject + predicate +
142
+ * object) — deliberately EXCLUDES confidence/validFrom/validTo/source, so
143
+ * re-asserting the SAME triple with different mutable fields always maps to
144
+ * the SAME id. Because Relationship.put() is a PUT-by-primary-key (Harper
145
+ * table semantics — see resources/Relationship.ts), writing to that same id
146
+ * again is a natural upsert: mutable fields update, the id stays stable, no
147
+ * pre-insert query and no race. Fields are joined with a NUL separator before
148
+ * hashing (not naive string concatenation) so e.g. agentId="a"+subject="bc"
149
+ * can never hash identically to agentId="ab"+subject="c" — free-text
150
+ * subject/predicate/object have no natural delimiter of their own.
151
+ *
152
+ * `agentId` is folded into the hash so the canonical id is PER-OWNER — two
153
+ * different agents asserting the identical (subject, predicate, object)
154
+ * triple get two different ids, never a cross-agent collision/overwrite (the
155
+ * write path also stamps `agentId` from the server-verified auth verdict,
156
+ * never the body — see resources/Relationship.ts's put() — so even a
157
+ * maliciously-crafted URL id can't make a foreign agent's row visible to the
158
+ * wrong owner; it can only self-collide with the calling agent's own rows).
159
+ *
160
+ * Exported (not just used internally by RelationshipApi.write()) so the CLI's
161
+ * mirrored implementation (src/cli.ts's `relationship add` command, which
162
+ * cannot import this workspace package into the published `@tpsdev-ai/flair`
163
+ * CLI bundle — same reasoning as its Memory-id-generation mirroring) can be
164
+ * cross-checked against this one in tests, and so any other integration
165
+ * package can compute the same id a relationship will land at without a
166
+ * round-trip.
167
+ */
168
+ export declare function canonicalRelationshipId(agentId: string, subject: string, predicate: string, object: string): string;
169
+ declare class RelationshipApi {
170
+ private client;
171
+ constructor(client: FlairClient);
172
+ /**
173
+ * Assert (write) a relationship triple: "record that <subject> <predicate>
174
+ * <object>". Always writes to the canonical id (canonicalRelationshipId
175
+ * above), so:
176
+ *
177
+ * (a) Re-asserting the SAME triple (same subject/predicate/object, same
178
+ * agentId) UPSERTS the existing row — mutable fields (confidence,
179
+ * validFrom/validTo, source) update, the id and createdAt-derived
180
+ * identity stay stable. No duplicate rows from re-assertion.
181
+ * (b) A CONTRADICTING triple with the same subject/predicate/object but a
182
+ * different `validTo` OVERWRITES the prior row's validTo too (the
183
+ * old value is lost) — acceptable: the graph wants the CURRENT state
184
+ * of a relationship, not a full history chain (Memory's
185
+ * supersedes-chain is overkill here).
186
+ * (c) A DIFFERENT predicate (e.g. "nathan manages flair" superseded by
187
+ * "nathan advises flair") hashes to a DIFFERENT id — a NEW row, and
188
+ * the OLD triple is NOT auto-closed. To contradict a prior
189
+ * relationship under a different predicate, set its `validTo` (via a
190
+ * second write with the OLD subject/predicate/object) or delete it,
191
+ * THEN write the new one.
192
+ *
193
+ * Never suppresses the write (same invariant as MemoryApi.write() — see
194
+ * flair#526's history for why "found something similar, don't write" is
195
+ * the wrong default): dedup here is pure upsert-by-canonical-id, not a
196
+ * near-duplicate signal.
197
+ */
198
+ write(input: {
199
+ subject: string;
200
+ predicate: string;
201
+ object: string;
202
+ /** 0.0–1.0, how certain (1.0 = explicitly stated). Server default: 1.0. */
203
+ confidence?: number;
204
+ /** ISO timestamp — when this relationship became true. Server default: now. */
205
+ validFrom?: string;
206
+ /** ISO timestamp — when it ended. Leave unset for an active relationship;
207
+ * set it on a prior write to close out a relationship you're contradicting. */
208
+ validTo?: string;
209
+ /** Where this was learned (memory ID, conversation, etc.). */
210
+ source?: string;
211
+ }): Promise<Relationship>;
212
+ /** Get a relationship by canonical id (or any id, e.g. one openclaw wrote
213
+ * under its own convention). Returns null on 404 (not found / not yours). */
214
+ get(id: string): Promise<Relationship | null>;
215
+ /** Delete a relationship by id. */
216
+ delete(id: string): Promise<void>;
217
+ }
131
218
  declare class SoulApi {
132
219
  private client;
133
220
  constructor(client: FlairClient);
package/dist/client.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * const results = await flair.memory.search('that thing')
8
8
  * const ctx = await flair.bootstrap({ maxTokens: 4000 })
9
9
  */
10
- import { createPrivateKey } from "node:crypto";
10
+ import { createHash, createPrivateKey } from "node:crypto";
11
11
  import { loadPrivateKey, resolveKeyPath, signRequest } from "./auth.js";
12
12
  const DEFAULT_URL = "http://localhost:19926";
13
13
  const DEFAULT_TIMEOUT = 30_000;
@@ -15,6 +15,7 @@ export class FlairClient {
15
15
  url;
16
16
  agentId;
17
17
  memory;
18
+ relationship;
18
19
  soul;
19
20
  privateKey = null;
20
21
  keyResolved = false;
@@ -37,6 +38,7 @@ export class FlairClient {
37
38
  this.basicAuth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
38
39
  }
39
40
  this.memory = new MemoryApi(this);
41
+ this.relationship = new RelationshipApi(this);
40
42
  this.soul = new SoulApi(this);
41
43
  }
42
44
  resolveKey() {
@@ -285,6 +287,114 @@ class MemoryApi {
285
287
  await this.client.request("DELETE", `/Memory/${id}`);
286
288
  }
287
289
  }
290
+ // ─── Relationship API ───────────────────────────────────────────────────────
291
+ /**
292
+ * Canonical, per-owner, deterministic Relationship id (relationship-write-path,
293
+ * K&S-approved refinement) — `base64url(SHA-256(lowercased agentId+subject+
294
+ * predicate+object))`, truncated to the first 16 bytes (22 base64url chars,
295
+ * 128-bit collision resistance). A REAL cryptographic hash on purpose
296
+ * (`crypto.createHash('sha256')`, NOT `Bun.hash` or any weak/platform-specific
297
+ * hash) — Sherlock: blocks intentional collision attacks; Kern: portable if
298
+ * flair ever runs on another runtime.
299
+ *
300
+ * Hashes ONLY the triple-identity fields (agentId + subject + predicate +
301
+ * object) — deliberately EXCLUDES confidence/validFrom/validTo/source, so
302
+ * re-asserting the SAME triple with different mutable fields always maps to
303
+ * the SAME id. Because Relationship.put() is a PUT-by-primary-key (Harper
304
+ * table semantics — see resources/Relationship.ts), writing to that same id
305
+ * again is a natural upsert: mutable fields update, the id stays stable, no
306
+ * pre-insert query and no race. Fields are joined with a NUL separator before
307
+ * hashing (not naive string concatenation) so e.g. agentId="a"+subject="bc"
308
+ * can never hash identically to agentId="ab"+subject="c" — free-text
309
+ * subject/predicate/object have no natural delimiter of their own.
310
+ *
311
+ * `agentId` is folded into the hash so the canonical id is PER-OWNER — two
312
+ * different agents asserting the identical (subject, predicate, object)
313
+ * triple get two different ids, never a cross-agent collision/overwrite (the
314
+ * write path also stamps `agentId` from the server-verified auth verdict,
315
+ * never the body — see resources/Relationship.ts's put() — so even a
316
+ * maliciously-crafted URL id can't make a foreign agent's row visible to the
317
+ * wrong owner; it can only self-collide with the calling agent's own rows).
318
+ *
319
+ * Exported (not just used internally by RelationshipApi.write()) so the CLI's
320
+ * mirrored implementation (src/cli.ts's `relationship add` command, which
321
+ * cannot import this workspace package into the published `@tpsdev-ai/flair`
322
+ * CLI bundle — same reasoning as its Memory-id-generation mirroring) can be
323
+ * cross-checked against this one in tests, and so any other integration
324
+ * package can compute the same id a relationship will land at without a
325
+ * round-trip.
326
+ */
327
+ export function canonicalRelationshipId(agentId, subject, predicate, object) {
328
+ const material = [agentId, subject, predicate, object].join("\u0000").toLowerCase();
329
+ return createHash("sha256").update(material, "utf8").digest().subarray(0, 16).toString("base64url");
330
+ }
331
+ class RelationshipApi {
332
+ client;
333
+ constructor(client) {
334
+ this.client = client;
335
+ }
336
+ /**
337
+ * Assert (write) a relationship triple: "record that <subject> <predicate>
338
+ * <object>". Always writes to the canonical id (canonicalRelationshipId
339
+ * above), so:
340
+ *
341
+ * (a) Re-asserting the SAME triple (same subject/predicate/object, same
342
+ * agentId) UPSERTS the existing row — mutable fields (confidence,
343
+ * validFrom/validTo, source) update, the id and createdAt-derived
344
+ * identity stay stable. No duplicate rows from re-assertion.
345
+ * (b) A CONTRADICTING triple with the same subject/predicate/object but a
346
+ * different `validTo` OVERWRITES the prior row's validTo too (the
347
+ * old value is lost) — acceptable: the graph wants the CURRENT state
348
+ * of a relationship, not a full history chain (Memory's
349
+ * supersedes-chain is overkill here).
350
+ * (c) A DIFFERENT predicate (e.g. "nathan manages flair" superseded by
351
+ * "nathan advises flair") hashes to a DIFFERENT id — a NEW row, and
352
+ * the OLD triple is NOT auto-closed. To contradict a prior
353
+ * relationship under a different predicate, set its `validTo` (via a
354
+ * second write with the OLD subject/predicate/object) or delete it,
355
+ * THEN write the new one.
356
+ *
357
+ * Never suppresses the write (same invariant as MemoryApi.write() — see
358
+ * flair#526's history for why "found something similar, don't write" is
359
+ * the wrong default): dedup here is pure upsert-by-canonical-id, not a
360
+ * near-duplicate signal.
361
+ */
362
+ async write(input) {
363
+ const id = canonicalRelationshipId(this.client.agentId, input.subject, input.predicate, input.object);
364
+ const record = {
365
+ id,
366
+ subject: input.subject,
367
+ predicate: input.predicate,
368
+ object: input.object,
369
+ };
370
+ if (input.confidence !== undefined)
371
+ record.confidence = input.confidence;
372
+ if (input.validFrom !== undefined)
373
+ record.validFrom = input.validFrom;
374
+ if (input.validTo !== undefined)
375
+ record.validTo = input.validTo;
376
+ if (input.source !== undefined)
377
+ record.source = input.source;
378
+ const response = await this.client.request("PUT", `/Relationship/${id}`, record);
379
+ return { ...record, id, agentId: this.client.agentId, ...(response ?? {}) };
380
+ }
381
+ /** Get a relationship by canonical id (or any id, e.g. one openclaw wrote
382
+ * under its own convention). Returns null on 404 (not found / not yours). */
383
+ async get(id) {
384
+ try {
385
+ return await this.client.request("GET", `/Relationship/${id}`);
386
+ }
387
+ catch (e) {
388
+ if (e instanceof FlairError && e.status === 404)
389
+ return null;
390
+ throw e;
391
+ }
392
+ }
393
+ /** Delete a relationship by id. */
394
+ async delete(id) {
395
+ await this.client.request("DELETE", `/Relationship/${id}`);
396
+ }
397
+ }
288
398
  // ─── Soul API ───────────────────────────────────────────────────────────────
289
399
  class SoulApi {
290
400
  client;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { FlairClient, FlairError } from "./client.js";
1
+ export { FlairClient, FlairError, canonicalRelationshipId } from "./client.js";
2
2
  export { loadPrivateKey, resolveKeyPath, signRequest } from "./auth.js";
3
- export type { FlairClientConfig, Memory, MemoryType, Durability, Visibility, SoulEntry, SearchResult, BootstrapResult, } from "./types.js";
3
+ export type { FlairClientConfig, Memory, MemoryType, Durability, Visibility, SoulEntry, SearchResult, BootstrapResult, Relationship, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { FlairClient, FlairError } from "./client.js";
1
+ export { FlairClient, FlairError, canonicalRelationshipId } from "./client.js";
2
2
  export { loadPrivateKey, resolveKeyPath, signRequest } from "./auth.js";
package/dist/types.d.ts CHANGED
@@ -54,6 +54,36 @@ export interface Memory {
54
54
  */
55
55
  deduped?: boolean;
56
56
  }
57
+ /**
58
+ * An entity-to-entity relationship triple (subject/predicate/object), with
59
+ * temporal validity and per-owner canonical dedup (see RelationshipApi.write()
60
+ * in client.ts for the canonical-id scheme). Free-text, lowercased on write —
61
+ * NOT the attention-plane `type:value` entity vocabulary, NOT memory-id FKs.
62
+ */
63
+ export interface Relationship {
64
+ id: string;
65
+ agentId: string;
66
+ subject: string;
67
+ predicate: string;
68
+ object: string;
69
+ /** ISO timestamp — when this relationship became true. */
70
+ validFrom?: string;
71
+ /** ISO timestamp — when it ended (absent/null = still active). */
72
+ validTo?: string;
73
+ /** 0.0–1.0, how certain (1.0 = explicitly stated). Defaults server-side to 1.0. */
74
+ confidence?: number;
75
+ /** Where this was learned (memory ID, conversation, etc.). */
76
+ source?: string;
77
+ createdAt: string;
78
+ updatedAt?: string;
79
+ /** JSON blob, same shape as Memory.provenance — { v, verified: { agentId,
80
+ * timestamp }, claimed?: { model } }. Absent on rows written before this
81
+ * field existed (migration-equivalence: additive/nullable). */
82
+ provenance?: string;
83
+ /** Always true after a successful write() — the server never suppresses a
84
+ * relationship write. */
85
+ written?: boolean;
86
+ }
57
87
  /** A soul entry (persistent personality/values). */
58
88
  export interface SoulEntry {
59
89
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair-client",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Lightweight client for Flair — identity, memory, and soul for AI agents. Zero heavy dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",