@tpsdev-ai/flair 0.52.0 → 0.54.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.
Files changed (99) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1914 -15328
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1654 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +914 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1592 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/fabric-npm-install.js +87 -0
  43. package/dist/federation-verify.js +498 -0
  44. package/dist/fleet-verify.js +144 -21
  45. package/dist/install/clients.js +167 -0
  46. package/dist/lib/auth-resolve.js +76 -1
  47. package/dist/lib/daemon-liveness.js +131 -2
  48. package/dist/lib/doctor-config-path.js +61 -0
  49. package/dist/lib/doctor-federation-driver.js +189 -0
  50. package/dist/lib/doctor-run.js +40 -0
  51. package/dist/lib/entity-vocab-cli.js +3 -3
  52. package/dist/lib/federation-pair-identity.js +47 -0
  53. package/dist/lib/launchd-repair.js +199 -0
  54. package/dist/lib/ops-api-bind.js +115 -0
  55. package/dist/lib/owned-pins.js +219 -0
  56. package/dist/lib/stabilize-mqtt-network.js +123 -0
  57. package/dist/lib/uninstall-purge.js +218 -0
  58. package/dist/rem/restore.js +8 -10
  59. package/dist/resources/AgentReadPosition.js +74 -0
  60. package/dist/resources/Federation.js +8 -2
  61. package/dist/resources/Memory.js +4 -3
  62. package/dist/resources/MemoryBootstrap.js +41 -25
  63. package/dist/resources/MemoryCandidate.js +5 -6
  64. package/dist/resources/OrgEventCatchup.js +126 -47
  65. package/dist/resources/agent-read-position-lib.js +83 -0
  66. package/dist/resources/agent-read-position.js +120 -0
  67. package/dist/resources/embeddings-boot.js +32 -0
  68. package/dist/resources/federation-peer-liveness.js +73 -0
  69. package/dist/resources/health.js +68 -19
  70. package/dist/resources/mcp-tools.js +43 -279
  71. package/dist/resources/memory-visibility.js +3 -3
  72. package/dist/resources/migration-boot.js +59 -18
  73. package/dist/resources/migrations/embedding-stamp.js +20 -1
  74. package/dist/resources/migrations/recheck.js +43 -0
  75. package/dist/resources/migrations/runner.js +6 -1
  76. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  77. package/dist/resources/migrations/visibility-backfill.js +2 -2
  78. package/dist/resources/org-event-catchup-lib.js +47 -0
  79. package/dist/resources/record-owner-guard.js +1 -0
  80. package/dist/stamp-migration-verify.js +163 -0
  81. package/dist/stamp-outstanding.js +144 -0
  82. package/docs/api-reference.md +4 -2
  83. package/docs/deploying-on-fabric.md +11 -10
  84. package/docs/deployment.md +3 -1
  85. package/docs/federation.md +19 -0
  86. package/docs/hosted-on-fabric.md +3 -3
  87. package/docs/quickstart.md +2 -1
  88. package/docs/releasing.md +15 -7
  89. package/docs/spoke-bringup.md +10 -5
  90. package/docs/standalone-local.md +3 -1
  91. package/docs/upgrade.md +25 -6
  92. package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
  93. package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
  94. package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
  95. package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
  96. package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
  97. package/package.json +9 -4
  98. package/schemas/agent.graphql +15 -0
  99. package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
@@ -0,0 +1,296 @@
1
+ import { authFetch, defaultKeysDir, resolveAdminUser } from "../lib/auth-resolve.js";
2
+ import { existsSync } from "node:fs";
3
+ let cli;
4
+ /** Bind the cli-locals this module depends on. */
5
+ export function bindCli(fns) {
6
+ cli = fns;
7
+ }
8
+ function privKeyPath(...args) {
9
+ return cli.privKeyPath(...args);
10
+ }
11
+ function resolveHttpPort(...args) {
12
+ return cli.resolveHttpPort(...args);
13
+ }
14
+ function resolveOpsPort(...args) {
15
+ return cli.resolveOpsPort(...args);
16
+ }
17
+ export function register(program) {
18
+ // ─── flair reembed ────────────────────────────────────────────────────────────
19
+ //
20
+ // ROOT-CAUSE GUARD — recall graph correctness (recall-hnsw-graph-heal).
21
+ // `flair reembed` replaces the stored embedding of many rows IN PLACE (it
22
+ // clears embedding/embeddingModel, then re-PUTs through Memory.put()'s regen
23
+ // branch — the same bulk in-place re-embed path resources/migrations/
24
+ // embedding-stamp.ts uses). Historically, an OLDER (pre-fix) Harper's
25
+ // INCREMENTAL HNSW update left stale/asymmetric reverse edges under bulk
26
+ // re-embed, which collapsed prod recall in July. That engine bug is FIXED in
27
+ // the Harper this ships against (5.1.22) — its update path reconstructs the
28
+ // prior vector and does the reverse-edge cleanup the old build skipped — so a
29
+ // bulk re-embed no longer corrupts the graph. DEFENSE-IN-DEPTH RULE (a
30
+ // prudent, version-independent default, not a workaround for a live bug): pair
31
+ // any BULK re-embed with a structural graph REBUILD trigger rather than relying
32
+ // on incremental HNSW updates to converge (today: the
33
+ // `@indexed(type:"HNSW", M:16)` descriptor bump in schemas/memory.graphql,
34
+ // which makes Harper clear + rebuild the graph cleanly from the stored vectors
35
+ // on the next boot — see resources/migrations/graph-heal.ts). Do NOT use
36
+ // resources/MemoryReindex.ts's `_reindex` for graph correctness (it re-PUTs the
37
+ // same vector through the incremental path and rebuilds nothing). If a `flair
38
+ // reembed` run ever materially changes the vector space (e.g. a model swap),
39
+ // follow it with a deploy that trips the structural reindex (bump the HNSW
40
+ // descriptor / restart after a schema change).
41
+ program
42
+ .command("reembed")
43
+ .description("Re-generate embeddings for memories with stale or missing model tags")
44
+ .option("--agent <id>", "Agent ID to re-embed memories for (defaults to all agents with stale rows)")
45
+ .option("--stale-only", "Only re-embed memories with mismatched model tag")
46
+ .option("--dry-run", "Show count without modifying")
47
+ .option("--port <port>", "Harper HTTP port")
48
+ .option("--batch-size <n>", "Records per batch", "50")
49
+ .option("--delay-ms <ms>", "Delay between batches (ms)", "100")
50
+ .action(async (opts) => {
51
+ const port = resolveHttpPort(opts);
52
+ const baseUrl = `http://127.0.0.1:${port}`;
53
+ const agentId = opts.agent;
54
+ const staleOnly = opts.staleOnly ?? false;
55
+ const dryRun = opts.dryRun ?? false;
56
+ const batchSize = Number(opts.batchSize);
57
+ const delayMs = Number(opts.delayMs);
58
+ // flair#504 Phase 2: MUST match resources/embeddings-provider.ts's
59
+ // getModelId() — including THE GATE (EMBEDDING_PREFIXES_ENABLED), not
60
+ // just the suffix. Duplicated as literals, not imported, because
61
+ // src/cli.ts and resources/**.ts are separate build targets —
62
+ // tsconfig.cli.json's rootDir is "src" and only includes src/cli.ts +
63
+ // src/cli-shim.cts, and the published CLI package ships only dist/ built
64
+ // from that config (package.json's "files"), so resources/ isn't
65
+ // reachable from (or bundled into) the CLI binary. THE GATE is now ON
66
+ // (flipped, re-baselined through the ratchet gate — see
67
+ // embeddings-provider.ts's file header and PR #689 for the park history
68
+ // this flip revisits), so `currentModel` here is `<base>+searchprefix` —
69
+ // matching getModelId()'s gate-on return exactly. If
70
+ // EMBEDDING_PREFIXES_ENABLED or EMBEDDING_VARIANT ever changes in
71
+ // embeddings-provider.ts, update this block too — a drift here silently
72
+ // breaks `--stale-only`: it would compare every row's embeddingModel
73
+ // against the WRONG current-model string, so rows would read as already
74
+ // "current" (or as needing re-embed) out of sync with what getModelId()
75
+ // is actually stamping new writes with.
76
+ const EMBEDDING_PREFIXES_ENABLED = true; // MUST mirror resources/embeddings-provider.ts's gate
77
+ const EMBEDDING_VARIANT = "searchprefix";
78
+ // embedding-space-guard slice 1: getModelId() now stamps the ENGINE-QUALIFIED
79
+ // id `<engine>:<base>[+searchprefix]`. Duplicated as a literal here (separate
80
+ // build target — see above). A row is CURRENT-SPACE iff its stamp is the
81
+ // qualified id OR its one-time bare-name equivalent (today's corpus, stamped
82
+ // before the qualifier). Treat BOTH as current so `--stale-only` never
83
+ // re-embeds an already-correct bare-stamped row — that would loop forever
84
+ // (Memory.put re-stamps it QUALIFIED, still "!= bare" under a single-value
85
+ // check). Keep in lockstep with resources/embeddings-provider.ts's
86
+ // getModelId()/EMBEDDING_ENGINE and resources/embedding-space-guard.ts's
87
+ // normalizeStamp().
88
+ const EMBEDDING_ENGINE = "gguf";
89
+ const baseModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
90
+ const bareCurrentModel = EMBEDDING_PREFIXES_ENABLED ? `${baseModel}+${EMBEDDING_VARIANT}` : baseModel;
91
+ const currentModel = `${EMBEDDING_ENGINE}:${bareCurrentModel}`;
92
+ const isCurrentSpace = (stamp) => stamp === currentModel || stamp === bareCurrentModel;
93
+ if (agentId) {
94
+ console.log(`Re-embedding memories for agent: ${agentId}`);
95
+ }
96
+ else {
97
+ console.log("Re-embedding memories for all agents with stale rows");
98
+ }
99
+ console.log(`Current model: ${currentModel}`);
100
+ if (staleOnly)
101
+ console.log("Mode: stale-only (skipping up-to-date memories)");
102
+ if (dryRun)
103
+ console.log("Mode: dry-run (no modifications)");
104
+ console.log("");
105
+ // When no agent specified, use admin auth to fetch all memories
106
+ if (!agentId) {
107
+ const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
108
+ if (!adminPass) {
109
+ console.error("❌ Admin password required when --agent is not specified (set FLAIR_ADMIN_PASS or HDB_ADMIN_PASSWORD)");
110
+ process.exit(1);
111
+ }
112
+ // Fetch every memory via the Harper ops API (search_by_conditions on the
113
+ // Memory table) rather than POST /SemanticSearch. SemanticSearch goes
114
+ // through the HNSW cosine index, which throws "Cosine distance comparison
115
+ // requires an array" against rows whose stored embedding shape is
116
+ // incompatible with the running Harper version (e.g. data written under
117
+ // harper@5.0.1 read under 5.0.9). The ops API bypasses the
118
+ // vector index — exactly what we need when the goal is to replace every
119
+ // embedding with a freshly-computed one. Without this path, `flair
120
+ // reembed` could not recover from the very condition it exists to fix.
121
+ const opsPort = resolveOpsPort(opts);
122
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPass}`).toString("base64")}`;
123
+ // Harper rejects empty-value conditions ("not indexed for nulls"). Use
124
+ // `createdAt > 1970-01-01` as the "select all" pattern: every Memory row
125
+ // has a createdAt, the index is built, and the comparison is total.
126
+ const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json", Authorization: opsAuth },
129
+ body: JSON.stringify({
130
+ operation: "search_by_conditions",
131
+ database: "flair",
132
+ table: "Memory",
133
+ operator: "and",
134
+ conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }],
135
+ get_attributes: ["*"],
136
+ limit: 100000,
137
+ }),
138
+ signal: AbortSignal.timeout(60_000),
139
+ });
140
+ if (!searchRes.ok) {
141
+ console.error(`❌ Failed to fetch memories via ops API: ${searchRes.status}`);
142
+ process.exit(1);
143
+ }
144
+ const raw = await searchRes.json();
145
+ const allMemories = Array.isArray(raw) ? raw : (raw?.results ?? []);
146
+ // Group by agentId
147
+ const byAgent = new Map();
148
+ for (const m of allMemories) {
149
+ if (!m.content)
150
+ continue;
151
+ if (staleOnly && isCurrentSpace(m.embeddingModel))
152
+ continue;
153
+ const agent = m.agentId || "unknown";
154
+ if (!byAgent.has(agent))
155
+ byAgent.set(agent, []);
156
+ byAgent.get(agent).push(m);
157
+ }
158
+ // Process each agent
159
+ let totalProcessed = 0;
160
+ let totalErrors = 0;
161
+ const agentCount = byAgent.size;
162
+ let agentIndex = 0;
163
+ for (const [agent, memories] of byAgent) {
164
+ agentIndex++;
165
+ console.log(`\nAgent ${agentIndex}/${agentCount}: ${agent}`);
166
+ console.log(` Memories to re-embed: ${memories.length}`);
167
+ const keysDir = defaultKeysDir();
168
+ const privPath = privKeyPath(agent, keysDir);
169
+ if (!existsSync(privPath)) {
170
+ console.error(` ❌ Key not found: ${privPath} — skipping`);
171
+ continue;
172
+ }
173
+ if (dryRun)
174
+ continue;
175
+ let processed = 0;
176
+ let errors = 0;
177
+ for (let i = 0; i < memories.length; i += batchSize) {
178
+ const batch = memories.slice(i, i + batchSize);
179
+ for (const memory of batch) {
180
+ try {
181
+ const updateRes = await authFetch(baseUrl, agent, privPath, "PUT", `/Memory/${memory.id}`, {
182
+ id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || agent,
183
+ });
184
+ if (updateRes.ok)
185
+ processed++;
186
+ else
187
+ errors++;
188
+ }
189
+ catch {
190
+ errors++;
191
+ }
192
+ }
193
+ const pct = Math.round(((i + batch.length) / memories.length) * 100);
194
+ process.stdout.write(` \r Re-embedded ${processed}/${memories.length} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
195
+ if (i + batchSize < memories.length)
196
+ await new Promise(r => setTimeout(r, delayMs));
197
+ }
198
+ console.log(`\n ✅ Agent ${agent}: ${processed} updated, ${errors} errors`);
199
+ totalProcessed += processed;
200
+ totalErrors += errors;
201
+ }
202
+ console.log(`\n\n✅ Re-embedding complete: ${totalProcessed} updated, ${totalErrors} errors`);
203
+ return;
204
+ }
205
+ // Single-agent path. Same rationale as above: fetch via the ops API
206
+ // (search_by_value on agentId) so the vector index isn't in the read path.
207
+ // This requires admin pass — fall back to the old SemanticSearch fetch only
208
+ // if no admin pass is available, since that path still works on
209
+ // version-matched data and requires only the agent's own key.
210
+ const keysDir = defaultKeysDir();
211
+ const privPath = privKeyPath(agentId, keysDir);
212
+ if (!existsSync(privPath)) {
213
+ console.error(`❌ Key not found: ${privPath}`);
214
+ process.exit(1);
215
+ }
216
+ const adminPassSingle = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
217
+ let allMemories = [];
218
+ if (adminPassSingle) {
219
+ const opsPort = resolveOpsPort(opts);
220
+ const opsAuth = `Basic ${Buffer.from(`${resolveAdminUser(undefined)}:${adminPassSingle}`).toString("base64")}`;
221
+ const searchRes = await fetch(`http://127.0.0.1:${opsPort}/`, {
222
+ method: "POST",
223
+ headers: { "Content-Type": "application/json", Authorization: opsAuth },
224
+ body: JSON.stringify({
225
+ operation: "search_by_value",
226
+ database: "flair",
227
+ table: "Memory",
228
+ search_attribute: "agentId",
229
+ search_value: agentId,
230
+ get_attributes: ["*"],
231
+ }),
232
+ signal: AbortSignal.timeout(60_000),
233
+ });
234
+ if (!searchRes.ok) {
235
+ console.error(`❌ Failed to fetch memories via ops API: ${searchRes.status}`);
236
+ process.exit(1);
237
+ }
238
+ const raw = await searchRes.json();
239
+ allMemories = Array.isArray(raw) ? raw : (raw?.results ?? []);
240
+ }
241
+ else {
242
+ const searchRes = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
243
+ agentId, limit: 10000,
244
+ });
245
+ if (!searchRes.ok) {
246
+ console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
247
+ process.exit(1);
248
+ }
249
+ const data = await searchRes.json();
250
+ allMemories = data.results ?? [];
251
+ }
252
+ const candidates = allMemories.filter((m) => {
253
+ if (!m.content)
254
+ return false;
255
+ if (staleOnly)
256
+ return !m.embeddingModel || !isCurrentSpace(m.embeddingModel);
257
+ return true;
258
+ });
259
+ const total = candidates.length;
260
+ const skipped = allMemories.length - total;
261
+ console.log(`Total memories: ${allMemories.length}`);
262
+ console.log(`Candidates for re-embedding: ${total}`);
263
+ if (skipped > 0)
264
+ console.log(`Skipped (up-to-date): ${skipped}`);
265
+ if (dryRun || total === 0) {
266
+ if (total === 0)
267
+ console.log("\n✅ All memories are up-to-date!");
268
+ return;
269
+ }
270
+ console.log("");
271
+ let processed = 0;
272
+ let errors = 0;
273
+ for (let i = 0; i < candidates.length; i += batchSize) {
274
+ const batch = candidates.slice(i, i + batchSize);
275
+ for (const memory of batch) {
276
+ try {
277
+ const updateRes = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${memory.id}`, {
278
+ id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || opts.agent,
279
+ });
280
+ if (updateRes.ok)
281
+ processed++;
282
+ else
283
+ errors++;
284
+ }
285
+ catch {
286
+ errors++;
287
+ }
288
+ }
289
+ const pct = Math.round(((i + batch.length) / total) * 100);
290
+ process.stdout.write(`\rRe-embedded ${processed}/${total} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
291
+ if (i + batchSize < candidates.length)
292
+ await new Promise(r => setTimeout(r, delayMs));
293
+ }
294
+ console.log(`\n\n✅ Re-embedding complete: ${processed} updated, ${errors} errors`);
295
+ });
296
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash } from "node:crypto";
2
+ let cli;
3
+ /** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
4
+ export function bindCli(fns) {
5
+ cli = fns;
6
+ }
7
+ const api = (method, path, body, options) => cli.api(method, path, body, options);
8
+ const resolveSigningAgentId = (opts, command) => cli.resolveSigningAgentId(opts, command);
9
+ // ─── flair relationship add ──────────────────────────────────────────────────
10
+ //
11
+ // Ergonomic agent-directed write surface for the Relationship graph
12
+ // (relationship-write-path spec): an explicit subject/predicate/object triple
13
+ // ("record that <subject> <predicate> <object>"), distinct from a free-text
14
+ // Memory. Mirrors `flair memory add`'s shape (--agent required, signed via
15
+ // the shared `api()` helper — see api()'s doc above for the Ed25519
16
+ // resolution order) rather than hand-rolling a signer, per this repo's
17
+ // existing convention (flair orgevent does hand-roll one because OrgEvent.put()
18
+ // self-verifies authorId against the signature; Relationship doesn't need that
19
+ // — the server stamps agentId from the verdict regardless of what's sent).
20
+ //
21
+ // PUTs to the CANONICAL id (see canonicalRelationshipId below), not a random
22
+ // one — re-running this command with the SAME subject/predicate/object
23
+ // UPSERTS the existing row (confidence/validTo/source refresh) instead of
24
+ // creating a duplicate. This mirrors flair-client's RelationshipApi.write()
25
+ // (packages/flair-client/src/client.ts) BYTE FOR BYTE — the CLI can't import
26
+ // that workspace package into the published @tpsdev-ai/flair bundle (same
27
+ // reasoning as the existing Memory-id-generation mirroring a few thousand
28
+ // lines up), so the algorithm is duplicated here rather than shared. A
29
+ // cross-check test (test/unit/cli-relationship-add.test.ts) pins the two
30
+ // implementations to identical output so they can't silently drift apart —
31
+ // a drift here would mean the CLI and the MCP tool/RelationshipApi land the
32
+ // SAME triple at TWO different ids, defeating the whole dedup guarantee.
33
+ function canonicalRelationshipId(agentId, subject, predicate, object) {
34
+ const material = [agentId, subject, predicate, object].join("\u0000").toLowerCase();
35
+ return createHash("sha256").update(material, "utf8").digest().subarray(0, 16).toString("base64url");
36
+ }
37
+ /** Register the `flair relationship` command group (flair#1634). */
38
+ export function register(program) {
39
+ const relationship = program.command("relationship").description("Manage agent relationship triples (knowledge graph)");
40
+ relationship.command("add")
41
+ .description("Record that <subject> <predicate> <object> — an explicit entity-to-entity relationship triple. " +
42
+ "Re-asserting the SAME triple (same subject/predicate/object) UPSERTS the existing row rather than " +
43
+ "duplicating it. Predicate is free text; recommended vocabulary: manages, works_on, reviews, depends_on, " +
44
+ "replaces, owns, reports_to, advises. To CONTRADICT a prior relationship: changing the predicate creates " +
45
+ "a SEPARATE row and does NOT auto-close the old one — re-assert the OLD triple with --valid-to set to now " +
46
+ "(or delete it) before/after writing the new one.")
47
+ .requiredOption("--agent <id>")
48
+ .requiredOption("--subject <text>", "Source entity (e.g. 'nathan')")
49
+ .requiredOption("--predicate <text>", "Relationship type, free text (e.g. 'manages')")
50
+ .requiredOption("--object <text>", "Target entity (e.g. 'flair')")
51
+ .option("--confidence <n>", "0.0-1.0, how certain (default 1.0 = explicitly stated)")
52
+ .option("--valid-from <iso>", "ISO timestamp this relationship became true (default: now)")
53
+ .option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
54
+ .option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
55
+ .action(async (opts) => {
56
+ const { agentId, source } = resolveSigningAgentId(opts, "relationship add");
57
+ const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
58
+ const body = {
59
+ id,
60
+ agentId: opts.agent,
61
+ subject: opts.subject,
62
+ predicate: opts.predicate,
63
+ object: opts.object,
64
+ };
65
+ if (opts.confidence !== undefined)
66
+ body.confidence = Number(opts.confidence);
67
+ if (opts.validFrom)
68
+ body.validFrom = opts.validFrom;
69
+ if (opts.validTo)
70
+ body.validTo = opts.validTo;
71
+ if (opts.source)
72
+ body.source = opts.source;
73
+ const out = await api("PUT", `/Relationship/${id}`, body, { agentId, agentIdSource: source });
74
+ console.log(JSON.stringify(out, null, 2));
75
+ });
76
+ }