@tpsdev-ai/flair 0.5.6 → 0.6.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
@@ -94,6 +94,9 @@ Context-signal-aware preloading. Bootstrap reads active project context, recent
94
94
  ### Auto Entity Detection
95
95
  Passive extraction of entities from memory content on write. Entities are indexed automatically — no tagging required. Feeds the relationship graph without agent intervention.
96
96
 
97
+ ### Memory Bridges
98
+ Pluggable import/export to foreign memory systems. Every agent-memory format (agentic-stack, Mem0, Letta, Anthropic memory, the next viral one) shouldn't need its own Flair PR. Bridges give you one contract with two shapes — a YAML descriptor for file-format targets or a code plugin for API targets — and a scaffold/test loop that lets an agent ship a working adapter in one pass. See [docs/bridges.md](docs/bridges.md).
99
+
97
100
  ## Quick Start
98
101
 
99
102
  ### Prerequisites
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Built-in bridge: agentic-stack
3
+ *
4
+ * Imports lessons from agentic-stack's `.agent/memory/semantic/lessons.jsonl`
5
+ * (and similar conventional paths) into Flair memories tagged
6
+ * `source: "agentic-stack/lessons"`.
7
+ *
8
+ * The descriptor is an in-tree typed object rather than a YAML string —
9
+ * built-ins ship inside the @tpsdev-ai/flair package and don't need the
10
+ * YAML round-trip a user-authored descriptor goes through. The loader
11
+ * for user descriptors (`yaml-loader.ts`) and this object both produce
12
+ * a `YamlBridgeDescriptor`, so the runtime executor doesn't care which
13
+ * source they came from.
14
+ */
15
+ export const agenticStackDescriptor = {
16
+ name: "agentic-stack",
17
+ version: 1,
18
+ kind: "file",
19
+ description: "Import agentic-stack `.agent/memory/semantic/lessons.jsonl` into Flair persistent memories",
20
+ detect: {
21
+ anyExists: [
22
+ ".agent/AGENTS.md",
23
+ ".agent/memory/semantic/lessons.jsonl",
24
+ ],
25
+ },
26
+ import: {
27
+ sources: [{
28
+ path: ".agent/memory/semantic/lessons.jsonl",
29
+ format: "jsonl",
30
+ map: {
31
+ content: "$.claim",
32
+ subject: "$.topic",
33
+ tags: "$.tags[*]",
34
+ foreignId: "$.id",
35
+ durability: "persistent",
36
+ source: "agentic-stack/lessons",
37
+ },
38
+ }],
39
+ },
40
+ export: {
41
+ targets: [{
42
+ path: ".agent/memory/semantic/lessons.jsonl",
43
+ format: "jsonl",
44
+ // Only export memories durable enough to be worth round-tripping back
45
+ // into agentic-stack. ephemeral / standard scratch memory stays in Flair.
46
+ when: "durability in ['persistent', 'permanent']",
47
+ map: {
48
+ // Preserve the foreign id when round-tripping; if the memory was
49
+ // Flair-native it'll just be missing from the output (and import-side
50
+ // generates a new id on first import anyway).
51
+ id: "$.foreignId",
52
+ claim: "$.content",
53
+ topic: "$.subject",
54
+ tags: "$.tags[*]",
55
+ },
56
+ }],
57
+ },
58
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Built-in bridge registry.
3
+ *
4
+ * Each built-in ships as a typed `YamlBridgeDescriptor` (see
5
+ * `agentic-stack.ts`). The CLI passes this registry to `discover()` so
6
+ * built-ins surface in `flair bridge list` alongside user-authored
7
+ * bridges, and to the import/export runtime so it knows how to load
8
+ * the descriptor for a built-in name.
9
+ *
10
+ * To add a new built-in: write `src/bridges/builtins/<name>.ts`
11
+ * exporting a `YamlBridgeDescriptor`, then register it here.
12
+ */
13
+ import { agenticStackDescriptor } from "./agentic-stack.js";
14
+ function builtin(d) {
15
+ return {
16
+ discovered: {
17
+ name: d.name,
18
+ kind: d.kind,
19
+ source: "builtin",
20
+ path: `(builtin:${d.name})`,
21
+ description: d.description,
22
+ version: d.version,
23
+ },
24
+ descriptor: d,
25
+ };
26
+ }
27
+ /** All bridges shipped inside @tpsdev-ai/flair. Order doesn't matter. */
28
+ export const BUILTINS = [
29
+ builtin(agenticStackDescriptor),
30
+ ];
31
+ /** Map name → descriptor for O(1) runtime lookup. */
32
+ export const BUILTIN_BY_NAME = new Map(BUILTINS.map((b) => [b.discovered.name, b]));
33
+ /** What `discover()` consumes for the `builtins` option. */
34
+ export function builtinDiscoveryRecords() {
35
+ return BUILTINS.map((b) => b.discovered);
36
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Bridge discovery — scans the four conventional sources defined in
3
+ * FLAIR-BRIDGES.md §7:
4
+ *
5
+ * 1. Project YAML: <cwd>/.flair-bridge/*.yaml
6
+ * 2. User YAML: ~/.flair/bridges/*.yaml
7
+ * 3. npm packages: node_modules/flair-bridge-* | node_modules/@*\/flair-bridge-*
8
+ * 4. Built-ins: bundled adapters inside @tpsdev-ai/flair
9
+ *
10
+ * Discovery is metadata-only — it reads names, versions, and kinds without
11
+ * executing any code plugin. Runtime execution is slice 2.
12
+ */
13
+ import { promises as fsp } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join, dirname, basename } from "node:path";
16
+ async function exists(path) {
17
+ try {
18
+ await fsp.stat(path);
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function readYamlBridge(path) {
26
+ try {
27
+ const raw = await fsp.readFile(path, "utf-8");
28
+ // Lightweight parse — we only pull the top-level fields we advertise.
29
+ // Avoids pulling in a YAML dep just for discovery; a real parse happens
30
+ // later when the bridge runs. Deliberately string-only (no dynamic
31
+ // RegExp) so ReDoS lint rules stay happy. Top-level means no leading
32
+ // whitespace on the line — indented values (under import:/export:)
33
+ // are correctly ignored.
34
+ const lines = raw.split(/\r?\n/);
35
+ const get = (field) => {
36
+ const prefix = `${field}:`;
37
+ for (const line of lines) {
38
+ if (!line.startsWith(prefix))
39
+ continue;
40
+ const rest = line.slice(prefix.length);
41
+ // Accept "name: foo", "name:foo", "name: foo"
42
+ const value = rest.trim();
43
+ // Strip surrounding single or double quotes (yaml-ish)
44
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
45
+ return value.slice(1, -1);
46
+ }
47
+ return value;
48
+ }
49
+ return undefined;
50
+ };
51
+ const name = get("name");
52
+ if (!name)
53
+ return null;
54
+ const kindStr = get("kind") ?? "file";
55
+ const kind = kindStr === "api" ? "api" : "file";
56
+ const versionRaw = get("version");
57
+ const version = versionRaw ? parseInt(versionRaw, 10) : undefined;
58
+ const description = get("description");
59
+ return {
60
+ name,
61
+ kind,
62
+ source: path.includes(join(".flair", "bridges")) ? "user-yaml" : "project-yaml",
63
+ path,
64
+ description,
65
+ version: Number.isFinite(version) ? version : undefined,
66
+ };
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ async function scanYamlDir(dir) {
73
+ if (!(await exists(dir)))
74
+ return [];
75
+ let names;
76
+ try {
77
+ names = await fsp.readdir(dir);
78
+ }
79
+ catch {
80
+ return [];
81
+ }
82
+ const out = [];
83
+ for (const name of names) {
84
+ if (!/\.ya?ml$/i.test(name))
85
+ continue;
86
+ const parsed = await readYamlBridge(join(dir, name));
87
+ if (parsed)
88
+ out.push(parsed);
89
+ }
90
+ return out;
91
+ }
92
+ async function readNpmBridge(pkgDir) {
93
+ const pkgPath = join(pkgDir, "package.json");
94
+ if (!(await exists(pkgPath)))
95
+ return null;
96
+ try {
97
+ const raw = await fsp.readFile(pkgPath, "utf-8");
98
+ const json = JSON.parse(raw);
99
+ // Convention: the bridge's public name is what ends with `flair-bridge-<name>`.
100
+ // Package name may be `flair-bridge-foo` or `@scope/flair-bridge-foo`.
101
+ const pkgName = String(json.name ?? "");
102
+ const match = pkgName.match(/flair-bridge-([^/]+)$/);
103
+ if (!match)
104
+ return null;
105
+ const flairMeta = json.flair ?? {};
106
+ const kind = flairMeta.kind === "file" ? "file" : "api"; // npm bridges default to api
107
+ return {
108
+ name: match[1],
109
+ kind,
110
+ source: "npm-package",
111
+ path: pkgDir,
112
+ description: json.description,
113
+ version: typeof flairMeta.version === "number" ? flairMeta.version : 1,
114
+ };
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ async function scanModuleRoot(base) {
121
+ if (!(await exists(base)))
122
+ return [];
123
+ let entries;
124
+ try {
125
+ entries = await fsp.readdir(base);
126
+ }
127
+ catch {
128
+ return [];
129
+ }
130
+ const results = [];
131
+ for (const entry of entries) {
132
+ const full = join(base, entry);
133
+ if (entry.startsWith("flair-bridge-")) {
134
+ const b = await readNpmBridge(full);
135
+ if (b)
136
+ results.push(b);
137
+ }
138
+ else if (entry.startsWith("@")) {
139
+ // Scoped dir — enumerate one level deeper
140
+ let scoped;
141
+ try {
142
+ scoped = await fsp.readdir(full);
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ for (const s of scoped) {
148
+ if (s.startsWith("flair-bridge-")) {
149
+ const b = await readNpmBridge(join(full, s));
150
+ if (b)
151
+ results.push(b);
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return results;
157
+ }
158
+ /**
159
+ * Discover all installed bridges across the four conventional sources.
160
+ * Results are deduped by name; built-ins win, then project, then user,
161
+ * then npm (shadowing a more-specific adapter with a less-specific one
162
+ * is never desirable).
163
+ */
164
+ export async function discover(opts = {}) {
165
+ const cwd = opts.cwd ?? process.cwd();
166
+ const home = opts.home ?? homedir();
167
+ const moduleRoots = opts.moduleRoots ?? [
168
+ join(cwd, "node_modules"),
169
+ join(home, ".flair", "node_modules"),
170
+ ];
171
+ const [projectYaml, userYaml, ...moduleResults] = await Promise.all([
172
+ scanYamlDir(join(cwd, ".flair-bridge")),
173
+ scanYamlDir(join(home, ".flair", "bridges")),
174
+ ...moduleRoots.map((r) => scanModuleRoot(r)),
175
+ ]);
176
+ const all = [
177
+ ...(opts.builtins ?? []),
178
+ ...projectYaml,
179
+ ...userYaml,
180
+ ...moduleResults.flat(),
181
+ ];
182
+ // Dedup by name with source precedence (earlier wins since all[] is ordered).
183
+ const seen = new Map();
184
+ for (const b of all) {
185
+ if (!seen.has(b.name))
186
+ seen.set(b.name, b);
187
+ }
188
+ return Array.from(seen.values()).sort((a, b) => a.name.localeCompare(b.name));
189
+ }
190
+ // Exported helpers for tests & for the scaffold command, which needs to
191
+ // confirm a bridge name isn't already taken.
192
+ export const __test = { readYamlBridge, readNpmBridge, scanYamlDir, scanModuleRoot };
193
+ // Suppress "unused" warnings in strict builds — dirname/basename kept for future expansion.
194
+ void dirname;
195
+ void basename;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Minimal BridgeContext implementation for slice 2.
3
+ *
4
+ * Slice 2 bridges only exercise the YAML/declarative runtime, which doesn't
5
+ * hit ctx.fetch or ctx.cache. But the types and the contract must exist so
6
+ * slice-3 code-plugin bridges can drop in against the same object shape.
7
+ *
8
+ * Implementation is intentionally simple here:
9
+ * - fetch is a thin passthrough to global fetch (slice 3 adds the token
10
+ * bucket + rate-limiting + audit tap)
11
+ * - log writes to stderr with structured JSON so the operator can redirect
12
+ * and an agent caller can grep
13
+ * - cache is in-memory for the invocation (persisted cache lands later)
14
+ */
15
+ const DEFAULT_EMIT = (ev) => {
16
+ // One JSON object per line; stderr so stdout stays clean for bridge output
17
+ process.stderr.write(JSON.stringify(ev) + "\n");
18
+ };
19
+ export function makeContext(opts) {
20
+ const emit = opts.emit ?? DEFAULT_EMIT;
21
+ const cache = new Map();
22
+ const log = (level) => (message, meta) => {
23
+ emit({
24
+ bridge: opts.bridge,
25
+ level,
26
+ message,
27
+ meta,
28
+ timestamp: new Date().toISOString(),
29
+ });
30
+ };
31
+ return {
32
+ fetch: (input, init) => fetch(input, init),
33
+ log: {
34
+ debug: log("debug"),
35
+ info: log("info"),
36
+ warn: log("warn"),
37
+ error: log("error"),
38
+ },
39
+ cache: {
40
+ async get(key) {
41
+ const entry = cache.get(key);
42
+ if (!entry)
43
+ return null;
44
+ if (entry.expiresAt !== null && entry.expiresAt < Date.now()) {
45
+ cache.delete(key);
46
+ return null;
47
+ }
48
+ return entry.value;
49
+ },
50
+ async set(key, value, ttlSeconds) {
51
+ const expiresAt = ttlSeconds !== undefined ? Date.now() + ttlSeconds * 1000 : null;
52
+ cache.set(key, { value, expiresAt });
53
+ },
54
+ async del(key) {
55
+ cache.delete(key);
56
+ },
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Runtime executor for YAML (Shape A) bridges.
3
+ *
4
+ * `importFromYaml` takes a parsed `YamlBridgeDescriptor` plus a root directory
5
+ * and yields `BridgeMemory` records — one per source record after the
6
+ * descriptor's `map` has been applied. Callers stream the output into Flair
7
+ * via the CLI (slice 2b) or the HTTP API directly.
8
+ *
9
+ * Failures are always `BridgeRuntimeError` with LLM-readable
10
+ * `{bridge, op, path, record, field, expected, got, hint}`.
11
+ */
12
+ import { join, isAbsolute } from "node:path";
13
+ import { BridgeRuntimeError } from "../types.js";
14
+ import { parseRecords } from "./formats.js";
15
+ import { applyMap } from "./mapper.js";
16
+ const FLAIR_RESERVED_FIELDS_SET = new Set([
17
+ "contentHash",
18
+ "embedding",
19
+ "embeddingModel",
20
+ "retrievalCount",
21
+ "lastRetrieved",
22
+ "promotionStatus",
23
+ "_safetyFlags",
24
+ "createdBy",
25
+ "updatedBy",
26
+ "archivedBy",
27
+ ]);
28
+ /**
29
+ * Drive a YAML bridge's `import` block and yield `BridgeMemory` records.
30
+ * Runs sources in order; one failure in a source halts the stream (the
31
+ * spec's "every bridge error is structured" contract says we don't
32
+ * silently discard partial imports).
33
+ */
34
+ export async function* importFromYaml(descriptor, opts) {
35
+ if (!descriptor.import) {
36
+ throw new BridgeRuntimeError({
37
+ bridge: descriptor.name,
38
+ op: "import",
39
+ field: "import",
40
+ expected: "object with sources",
41
+ got: "missing",
42
+ hint: "descriptor has no `import` block — run `flair bridge export` instead, or add one",
43
+ });
44
+ }
45
+ for (let srcIdx = 0; srcIdx < descriptor.import.sources.length; srcIdx++) {
46
+ const source = descriptor.import.sources[srcIdx];
47
+ const resolvedPath = resolvePath(opts.cwd, source.path);
48
+ opts.ctx?.log.info(`importing source`, {
49
+ source: source.path,
50
+ format: source.format,
51
+ resolved: resolvedPath,
52
+ });
53
+ for await (const { record, recordIndex } of parseRecords(descriptor.name, resolvedPath, source.format)) {
54
+ const mapped = applyMap(source.map, record);
55
+ // Reject any attempt to set Flair-reserved fields (spec §4).
56
+ for (const f of Object.keys(mapped)) {
57
+ if (FLAIR_RESERVED_FIELDS_SET.has(f)) {
58
+ throw new BridgeRuntimeError({
59
+ bridge: descriptor.name,
60
+ op: "import",
61
+ path: resolvedPath,
62
+ record: recordIndex,
63
+ field: `map.${f}`,
64
+ expected: "non-reserved BridgeMemory field",
65
+ got: f,
66
+ hint: `Flair computes ${f} on ingest; bridges MUST NOT set it. Remove from the 'map:' block`,
67
+ });
68
+ }
69
+ }
70
+ // `content` is the only hard requirement from the spec (§4).
71
+ if (typeof mapped.content !== "string" || mapped.content.length === 0) {
72
+ throw new BridgeRuntimeError({
73
+ bridge: descriptor.name,
74
+ op: "import",
75
+ path: resolvedPath,
76
+ record: recordIndex,
77
+ field: "map.content",
78
+ expected: "non-empty string",
79
+ got: mapped.content === undefined ? "missing" : String(mapped.content),
80
+ hint: "every record must produce a non-empty `content`; check the source data and the 'map.content' expression",
81
+ });
82
+ }
83
+ // Cast through unknown to honor BridgeMemory's declared shape without
84
+ // trusting the mapper output.
85
+ const bridgeMemory = mapped;
86
+ yield bridgeMemory;
87
+ }
88
+ }
89
+ }
90
+ function resolvePath(cwd, p) {
91
+ return isAbsolute(p) ? p : join(cwd, p);
92
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Export runner — drives a YAML descriptor's `export` block.
3
+ *
4
+ * Reads memories from Flair (via an injected fetcher), filters them
5
+ * through the descriptor's `when:` predicate when present, applies the
6
+ * `map:` to produce shaped output records, and writes to the target via
7
+ * the format writer.
8
+ *
9
+ * Slice 3a supports Shape A (YAML descriptors) and the jsonl/json
10
+ * formats. Markdown-frontmatter and code-plugin (Shape B) export land
11
+ * in slice 3b/3c.
12
+ *
13
+ * As with import-runner, the Flair I/O is injected so the runner stays
14
+ * unit-testable without spinning up a real Flair instance.
15
+ */
16
+ import { isAbsolute, join } from "node:path";
17
+ import { BridgeRuntimeError } from "../types.js";
18
+ import { applyMap } from "./mapper.js";
19
+ import { evaluatePredicate } from "./predicate.js";
20
+ import { writeRecords } from "./writers.js";
21
+ export async function runExport(opts) {
22
+ if (!opts.descriptor.export) {
23
+ throw new BridgeRuntimeError({
24
+ bridge: opts.descriptor.name,
25
+ op: "export",
26
+ field: "export",
27
+ expected: "object with targets",
28
+ got: "missing",
29
+ hint: "descriptor has no `export` block — run `flair bridge import` instead, or add one",
30
+ });
31
+ }
32
+ const onProgress = opts.onProgress ?? (() => { });
33
+ const perTarget = [];
34
+ let total = 0;
35
+ let exported = 0;
36
+ // Pull all memories once and reuse across targets — typical case is a
37
+ // single target. If a descriptor has multiple targets each with its own
38
+ // when:, re-fetching per target would surprise the operator with N×
39
+ // backend calls.
40
+ const memories = [];
41
+ for await (const m of opts.fetchMemories(opts.filters ?? {})) {
42
+ memories.push(m);
43
+ total++;
44
+ }
45
+ for (let tIdx = 0; tIdx < opts.descriptor.export.targets.length; tIdx++) {
46
+ const target = opts.descriptor.export.targets[tIdx];
47
+ const resolvedPath = isAbsolute(target.path) ? target.path : join(opts.cwd, target.path);
48
+ // Apply when: filter
49
+ const passing = [];
50
+ if (target.when && target.when.trim()) {
51
+ let unparsableSeen = false;
52
+ for (let i = 0; i < memories.length; i++) {
53
+ const result = evaluatePredicate(target.when, memories[i]);
54
+ if (result === "unparsable") {
55
+ if (!unparsableSeen) {
56
+ unparsableSeen = true;
57
+ opts.ctx?.log.warn(`when: clause unparsable, including all records for this target`, {
58
+ when: target.when,
59
+ targetPath: resolvedPath,
60
+ });
61
+ }
62
+ passing.push(memories[i]);
63
+ }
64
+ else if (result === "match") {
65
+ passing.push(memories[i]);
66
+ }
67
+ else {
68
+ onProgress({ type: "memory-skipped", ordinal: i + 1, reason: `when: ${target.when}` });
69
+ }
70
+ }
71
+ }
72
+ else {
73
+ passing.push(...memories);
74
+ }
75
+ // Apply map: to each surviving memory
76
+ const shaped = [];
77
+ for (let i = 0; i < passing.length; i++) {
78
+ const out = applyMap(target.map, passing[i]);
79
+ // The ONLY hard requirement on output is that the map produced at
80
+ // least one field — empty mappings would produce empty JSONL lines
81
+ // which are technically valid but suspicious. Skip them with a hint.
82
+ if (Object.keys(out).length === 0) {
83
+ onProgress({ type: "memory-skipped", ordinal: i + 1, reason: "map produced no fields" });
84
+ continue;
85
+ }
86
+ shaped.push(out);
87
+ }
88
+ if (opts.dryRun) {
89
+ onProgress({ type: "target-skipped", path: resolvedPath, reason: "--dry-run" });
90
+ perTarget.push({ path: resolvedPath, written: 0 });
91
+ continue;
92
+ }
93
+ opts.ctx?.log.info(`exporting target`, {
94
+ target: target.path,
95
+ format: target.format,
96
+ records: shaped.length,
97
+ });
98
+ const { written } = await writeRecords(opts.descriptor.name, resolvedPath, target.format, shaped);
99
+ perTarget.push({ path: resolvedPath, written });
100
+ exported += written;
101
+ onProgress({ type: "target-write", path: resolvedPath, written });
102
+ }
103
+ onProgress({ type: "done", total, exported });
104
+ return { total, exported, perTarget };
105
+ }