@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 +3 -0
- package/dist/bridges/builtins/agentic-stack.js +58 -0
- package/dist/bridges/builtins/index.js +36 -0
- package/dist/bridges/discover.js +195 -0
- package/dist/bridges/runtime/context.js +59 -0
- package/dist/bridges/runtime/execute.js +92 -0
- package/dist/bridges/runtime/export-runner.js +105 -0
- package/dist/bridges/runtime/formats.js +136 -0
- package/dist/bridges/runtime/import-runner.js +107 -0
- package/dist/bridges/runtime/load-descriptor.js +46 -0
- package/dist/bridges/runtime/mapper.js +139 -0
- package/dist/bridges/runtime/predicate.js +122 -0
- package/dist/bridges/runtime/roundtrip.js +154 -0
- package/dist/bridges/runtime/writers.js +130 -0
- package/dist/bridges/runtime/yaml-loader.js +148 -0
- package/dist/bridges/scaffold.js +224 -0
- package/dist/bridges/types.js +30 -0
- package/dist/cli.js +1130 -240
- package/dist/deploy.js +161 -0
- package/dist/resources/ObservationCenter.js +19 -0
- package/dist/resources/health.js +443 -14
- package/package.json +6 -6
- package/ui/observation-center.html +91 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Round-trip test harness for bridges.
|
|
3
|
+
*
|
|
4
|
+
* A bridge passes the round-trip test iff:
|
|
5
|
+
* 1. Parse the fixture (or descriptor's first import.source.path) → pass1 BridgeMemory[]
|
|
6
|
+
* 2. Apply export.targets[0]: when-filter → map → write to tmp in the
|
|
7
|
+
* target's format
|
|
8
|
+
* 3. Re-import the tmp file using import.sources[0] → pass2 BridgeMemory[]
|
|
9
|
+
* 4. pass1 and pass2 must agree on the round-trip-stable fields from the
|
|
10
|
+
* spec (§8): content, subject, tags, durability.
|
|
11
|
+
*
|
|
12
|
+
* This exercises the full mapper + predicate + writer + parser chain —
|
|
13
|
+
* if a bridge author's descriptor has mapping bugs, the round-trip
|
|
14
|
+
* surfaces them before memories ever reach Flair.
|
|
15
|
+
*
|
|
16
|
+
* Implementation note: does NOT talk to Flair. Fixture-to-fixture only.
|
|
17
|
+
* Lives here so slice-3c code plugins can reuse the same harness.
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fsp } from "node:fs";
|
|
20
|
+
import { join, isAbsolute } from "node:path";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { randomUUID } from "node:crypto";
|
|
23
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
24
|
+
import { parseRecords } from "./formats.js";
|
|
25
|
+
import { applyMap } from "./mapper.js";
|
|
26
|
+
import { evaluatePredicate } from "./predicate.js";
|
|
27
|
+
import { writeRecords } from "./writers.js";
|
|
28
|
+
/** Fields the spec (§8) requires to survive round-trip. */
|
|
29
|
+
export const ROUND_TRIP_STABLE_FIELDS = ["content", "subject", "tags", "durability"];
|
|
30
|
+
export async function runRoundTrip(opts) {
|
|
31
|
+
const { descriptor, cwd } = opts;
|
|
32
|
+
if (!descriptor.import || descriptor.import.sources.length === 0) {
|
|
33
|
+
throw new BridgeRuntimeError({
|
|
34
|
+
bridge: descriptor.name,
|
|
35
|
+
op: "test",
|
|
36
|
+
field: "import",
|
|
37
|
+
expected: "descriptor with at least one import source",
|
|
38
|
+
got: "missing or empty",
|
|
39
|
+
hint: "round-trip needs an import block to start from. Add `import.sources:` to the descriptor.",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (!descriptor.export || descriptor.export.targets.length === 0) {
|
|
43
|
+
throw new BridgeRuntimeError({
|
|
44
|
+
bridge: descriptor.name,
|
|
45
|
+
op: "test",
|
|
46
|
+
field: "export",
|
|
47
|
+
expected: "descriptor with at least one export target",
|
|
48
|
+
got: "missing or empty",
|
|
49
|
+
hint: "round-trip needs an export block to round-trip through. Add `export.targets:` to the descriptor.",
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const importSource = descriptor.import.sources[0];
|
|
53
|
+
const exportTarget = descriptor.export.targets[0];
|
|
54
|
+
const resolvedFixture = resolvePath(cwd, opts.fixturePath ?? importSource.path);
|
|
55
|
+
const tmpDir = await fsp.mkdtemp(join(tmpdir(), `flair-bridge-test-${descriptor.name}-`));
|
|
56
|
+
const tmpPath = join(tmpDir, `roundtrip.${suffixForFormat(exportTarget.format)}`);
|
|
57
|
+
// Pass 1: fixture → BridgeMemory[]
|
|
58
|
+
const pass1 = [];
|
|
59
|
+
for await (const { record } of parseRecords(descriptor.name, resolvedFixture, importSource.format)) {
|
|
60
|
+
const mapped = applyMap(importSource.map, record);
|
|
61
|
+
pass1.push(mapped);
|
|
62
|
+
}
|
|
63
|
+
// Filter pass1 by the export target's when: (records filtered out wouldn't make it to the target).
|
|
64
|
+
const expected = [];
|
|
65
|
+
for (const m of pass1) {
|
|
66
|
+
if (!exportTarget.when || exportTarget.when.trim() === "") {
|
|
67
|
+
expected.push(m);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const result = evaluatePredicate(exportTarget.when, m);
|
|
71
|
+
if (result === "match" || result === "unparsable")
|
|
72
|
+
expected.push(m);
|
|
73
|
+
}
|
|
74
|
+
// Export phase: apply export.map, write to tmp
|
|
75
|
+
const shaped = [];
|
|
76
|
+
for (const m of expected) {
|
|
77
|
+
const out = applyMap(exportTarget.map, m);
|
|
78
|
+
if (Object.keys(out).length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
shaped.push(out);
|
|
81
|
+
}
|
|
82
|
+
await writeRecords(descriptor.name, tmpPath, exportTarget.format, shaped);
|
|
83
|
+
// Pass 2: re-import the tmp using import.sources[0]'s map + format
|
|
84
|
+
// (round-trip requires the bridge's own import map applied to what export wrote).
|
|
85
|
+
const pass2 = [];
|
|
86
|
+
for await (const { record } of parseRecords(descriptor.name, tmpPath, importSource.format)) {
|
|
87
|
+
const mapped = applyMap(importSource.map, record);
|
|
88
|
+
pass2.push(mapped);
|
|
89
|
+
}
|
|
90
|
+
// Match pass1[expected] with pass2 by key (foreignId if present, else content)
|
|
91
|
+
// and diff the stable fields.
|
|
92
|
+
const keyOf = (m) => m.foreignId ?? (m.content ? `content:${m.content}` : "");
|
|
93
|
+
const pass2ByKey = new Map();
|
|
94
|
+
for (const m of pass2)
|
|
95
|
+
pass2ByKey.set(keyOf(m), m);
|
|
96
|
+
const mismatches = [];
|
|
97
|
+
const missingInPass2 = [];
|
|
98
|
+
for (let i = 0; i < expected.length; i++) {
|
|
99
|
+
const a = expected[i];
|
|
100
|
+
const key = keyOf(a);
|
|
101
|
+
const b = pass2ByKey.get(key);
|
|
102
|
+
if (!b) {
|
|
103
|
+
missingInPass2.push({ ordinal: i + 1, key });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
for (const field of ROUND_TRIP_STABLE_FIELDS) {
|
|
107
|
+
if (!deepEqualField(a[field], b[field])) {
|
|
108
|
+
mismatches.push({ ordinal: i + 1, key, field, expected: a[field], got: b[field] });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Matched this pass2 entry; remove so leftover tracking works
|
|
112
|
+
pass2ByKey.delete(key);
|
|
113
|
+
}
|
|
114
|
+
const unexpectedInPass2 = Array.from(pass2ByKey.keys()).map((key) => ({ key }));
|
|
115
|
+
const passed = mismatches.length === 0 && missingInPass2.length === 0 && unexpectedInPass2.length === 0;
|
|
116
|
+
return {
|
|
117
|
+
passed,
|
|
118
|
+
expectedCount: expected.length,
|
|
119
|
+
actualCount: pass2.length,
|
|
120
|
+
mismatches,
|
|
121
|
+
missingInPass2,
|
|
122
|
+
unexpectedInPass2,
|
|
123
|
+
tmpExportPath: tmpPath,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function deepEqualField(a, b) {
|
|
127
|
+
// tags is the only array-valued stable field today; deep-equal shallowly
|
|
128
|
+
// (order matters per our mapping; if a bridge re-orders tags, that's a
|
|
129
|
+
// round-trip regression worth catching).
|
|
130
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
131
|
+
if (a.length !== b.length)
|
|
132
|
+
return false;
|
|
133
|
+
for (let i = 0; i < a.length; i++)
|
|
134
|
+
if (a[i] !== b[i])
|
|
135
|
+
return false;
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
// string / number / boolean / undefined: strict equality
|
|
139
|
+
return a === b;
|
|
140
|
+
}
|
|
141
|
+
function suffixForFormat(format) {
|
|
142
|
+
switch (format) {
|
|
143
|
+
case "jsonl": return "jsonl";
|
|
144
|
+
case "json": return "json";
|
|
145
|
+
case "yaml": return "yaml";
|
|
146
|
+
case "markdown-frontmatter": return "md";
|
|
147
|
+
default: return "out";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function resolvePath(cwd, p) {
|
|
151
|
+
return isAbsolute(p) ? p : join(cwd, p);
|
|
152
|
+
}
|
|
153
|
+
// Keep randomUUID imported; future slice may use it for named tmp files.
|
|
154
|
+
void randomUUID;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output writers for export targets. Slice 3a ships `jsonl` and `json`.
|
|
3
|
+
* `yaml` and `markdown-frontmatter` land with slice 3b alongside any
|
|
4
|
+
* built-in that needs them.
|
|
5
|
+
*
|
|
6
|
+
* Each writer takes a stream of "shaped output records" (whatever the
|
|
7
|
+
* descriptor's `map:` produced) and writes to a target file path.
|
|
8
|
+
* Atomic via tmp-file + rename, so a crash mid-write doesn't leave a
|
|
9
|
+
* half-written target.
|
|
10
|
+
*/
|
|
11
|
+
import { promises as fsp } from "node:fs";
|
|
12
|
+
import { dirname, basename } from "node:path";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import yaml from "js-yaml";
|
|
15
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
16
|
+
export async function writeRecords(bridge, path, format, records) {
|
|
17
|
+
switch (format) {
|
|
18
|
+
case "jsonl": return writeJsonl(bridge, path, records);
|
|
19
|
+
case "json": return writeJson(bridge, path, records);
|
|
20
|
+
case "yaml": return writeYaml(bridge, path, records);
|
|
21
|
+
case "markdown-frontmatter":
|
|
22
|
+
throw new BridgeRuntimeError({
|
|
23
|
+
bridge,
|
|
24
|
+
op: "export",
|
|
25
|
+
path,
|
|
26
|
+
field: "format",
|
|
27
|
+
expected: "jsonl | json | yaml",
|
|
28
|
+
got: "markdown-frontmatter",
|
|
29
|
+
hint: "markdown-frontmatter writer lands in slice 3b along with its reference adapter",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async function writeJsonl(bridge, path, records) {
|
|
34
|
+
const tmpPath = await stageTmp(bridge, path, "export");
|
|
35
|
+
let written = 0;
|
|
36
|
+
try {
|
|
37
|
+
const fh = await fsp.open(tmpPath, "w");
|
|
38
|
+
try {
|
|
39
|
+
for (const r of records) {
|
|
40
|
+
await fh.write(JSON.stringify(r) + "\n");
|
|
41
|
+
written++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await fh.close();
|
|
46
|
+
}
|
|
47
|
+
await fsp.rename(tmpPath, path);
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
await fsp.unlink(tmpPath).catch(() => { });
|
|
51
|
+
throw new BridgeRuntimeError({
|
|
52
|
+
bridge,
|
|
53
|
+
op: "export",
|
|
54
|
+
path,
|
|
55
|
+
field: "(write)",
|
|
56
|
+
expected: "successful write",
|
|
57
|
+
got: err?.message ?? String(err),
|
|
58
|
+
hint: `failed writing to ${path}: ${err?.message ?? err}`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return { written };
|
|
62
|
+
}
|
|
63
|
+
async function writeJson(bridge, path, records) {
|
|
64
|
+
const arr = [];
|
|
65
|
+
for (const r of records)
|
|
66
|
+
arr.push(r);
|
|
67
|
+
const tmpPath = await stageTmp(bridge, path, "export");
|
|
68
|
+
try {
|
|
69
|
+
await fsp.writeFile(tmpPath, JSON.stringify(arr, null, 2));
|
|
70
|
+
await fsp.rename(tmpPath, path);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
await fsp.unlink(tmpPath).catch(() => { });
|
|
74
|
+
throw new BridgeRuntimeError({
|
|
75
|
+
bridge,
|
|
76
|
+
op: "export",
|
|
77
|
+
path,
|
|
78
|
+
field: "(write)",
|
|
79
|
+
expected: "successful write",
|
|
80
|
+
got: err?.message ?? String(err),
|
|
81
|
+
hint: `failed writing to ${path}: ${err?.message ?? err}`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return { written: arr.length };
|
|
85
|
+
}
|
|
86
|
+
async function writeYaml(bridge, path, records) {
|
|
87
|
+
const arr = [];
|
|
88
|
+
for (const r of records)
|
|
89
|
+
arr.push(r);
|
|
90
|
+
const tmpPath = await stageTmp(bridge, path, "export");
|
|
91
|
+
try {
|
|
92
|
+
await fsp.writeFile(tmpPath, yaml.dump(arr));
|
|
93
|
+
await fsp.rename(tmpPath, path);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
await fsp.unlink(tmpPath).catch(() => { });
|
|
97
|
+
throw new BridgeRuntimeError({
|
|
98
|
+
bridge,
|
|
99
|
+
op: "export",
|
|
100
|
+
path,
|
|
101
|
+
field: "(write)",
|
|
102
|
+
expected: "successful write",
|
|
103
|
+
got: err?.message ?? String(err),
|
|
104
|
+
hint: `failed writing to ${path}: ${err?.message ?? err}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { written: arr.length };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Allocate a sibling temp file in the same directory as `targetPath`.
|
|
111
|
+
* Same-fs rename is atomic on POSIX; using a sibling guarantees that.
|
|
112
|
+
*/
|
|
113
|
+
async function stageTmp(bridge, targetPath, op) {
|
|
114
|
+
const dir = dirname(targetPath);
|
|
115
|
+
try {
|
|
116
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
throw new BridgeRuntimeError({
|
|
120
|
+
bridge,
|
|
121
|
+
op: "export",
|
|
122
|
+
path: targetPath,
|
|
123
|
+
field: "(mkdir)",
|
|
124
|
+
expected: "writable directory",
|
|
125
|
+
got: err?.message ?? String(err),
|
|
126
|
+
hint: `could not create directory ${dir}: ${err?.message ?? err}`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return `${dir}/.${basename(targetPath)}.${op}.${randomUUID().slice(0, 8)}.tmp`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML bridge descriptor loader.
|
|
3
|
+
*
|
|
4
|
+
* Reads a `.flair-bridge/<name>.yaml` (or `~/.flair/bridges/<name>.yaml`),
|
|
5
|
+
* parses it with js-yaml, and normalizes it into a typed
|
|
6
|
+
* `YamlBridgeDescriptor`. Validation is strict for required fields
|
|
7
|
+
* (`name`, `kind`, at least one of `import`/`export`), lenient elsewhere —
|
|
8
|
+
* unknown fields are preserved but ignored; the spec is allowed to grow.
|
|
9
|
+
*
|
|
10
|
+
* Errors are always `BridgeRuntimeError` with LLM-readable `{field, expected, got, hint}`.
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fsp } from "node:fs";
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
15
|
+
const VALID_FORMATS = [
|
|
16
|
+
"jsonl",
|
|
17
|
+
"json",
|
|
18
|
+
"yaml",
|
|
19
|
+
"markdown-frontmatter",
|
|
20
|
+
];
|
|
21
|
+
function failLoad(path, field, expected, got, hint) {
|
|
22
|
+
throw new BridgeRuntimeError({
|
|
23
|
+
bridge: "(unknown)",
|
|
24
|
+
op: "import",
|
|
25
|
+
path,
|
|
26
|
+
field,
|
|
27
|
+
expected,
|
|
28
|
+
got: typeof got === "string" ? got : JSON.stringify(got),
|
|
29
|
+
hint,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function fail(bridge, path, field, expected, got, hint) {
|
|
33
|
+
throw new BridgeRuntimeError({
|
|
34
|
+
bridge,
|
|
35
|
+
op: "import",
|
|
36
|
+
path,
|
|
37
|
+
field,
|
|
38
|
+
expected,
|
|
39
|
+
got: typeof got === "string" ? got : JSON.stringify(got),
|
|
40
|
+
hint,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function normalizeSourceTarget(bridge, path, fieldBase, raw) {
|
|
44
|
+
if (typeof raw !== "object" || raw === null) {
|
|
45
|
+
fail(bridge, path, fieldBase, "object", raw, `each entry under ${fieldBase} must be an object with 'path', 'format', and 'map'`);
|
|
46
|
+
}
|
|
47
|
+
const obj = raw;
|
|
48
|
+
if (typeof obj.path !== "string" || !obj.path) {
|
|
49
|
+
fail(bridge, path, `${fieldBase}.path`, "non-empty string", obj.path, `${fieldBase}.path must be a non-empty string`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof obj.format !== "string") {
|
|
52
|
+
fail(bridge, path, `${fieldBase}.format`, "string", obj.format, `${fieldBase}.format must be one of: ${VALID_FORMATS.join(", ")}`);
|
|
53
|
+
}
|
|
54
|
+
if (!VALID_FORMATS.includes(obj.format)) {
|
|
55
|
+
fail(bridge, path, `${fieldBase}.format`, VALID_FORMATS.join(" | "), obj.format, `unsupported format "${obj.format}"; supported: ${VALID_FORMATS.join(", ")}`);
|
|
56
|
+
}
|
|
57
|
+
if (typeof obj.map !== "object" || obj.map === null || Array.isArray(obj.map)) {
|
|
58
|
+
fail(bridge, path, `${fieldBase}.map`, "object", obj.map, `${fieldBase}.map must be an object of BridgeMemory-field → mapping expression`);
|
|
59
|
+
}
|
|
60
|
+
const mapIn = obj.map;
|
|
61
|
+
const map = {};
|
|
62
|
+
for (const [k, v] of Object.entries(mapIn)) {
|
|
63
|
+
if (typeof v !== "string") {
|
|
64
|
+
fail(bridge, path, `${fieldBase}.map.${k}`, "string expression", v, `map entries must be strings; got ${typeof v} for ${k}`);
|
|
65
|
+
}
|
|
66
|
+
map[k] = v;
|
|
67
|
+
}
|
|
68
|
+
const out = {
|
|
69
|
+
path: obj.path,
|
|
70
|
+
format: obj.format,
|
|
71
|
+
map,
|
|
72
|
+
};
|
|
73
|
+
if (typeof obj.when === "string")
|
|
74
|
+
out.when = obj.when;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
export async function loadYamlDescriptor(yamlPath) {
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = await fsp.readFile(yamlPath, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
failLoad(yamlPath, "(file)", "readable file", err?.code ?? "ENOENT", `could not read YAML file: ${err?.message ?? err}`);
|
|
84
|
+
}
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = yaml.load(raw);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
failLoad(yamlPath, "(yaml)", "valid YAML", err?.name ?? "parse error", `YAML parse failed: ${err?.message ?? err}`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
93
|
+
failLoad(yamlPath, "(root)", "mapping", parsed, "descriptor must be a YAML mapping at the top level");
|
|
94
|
+
}
|
|
95
|
+
const d = parsed;
|
|
96
|
+
if (typeof d.name !== "string" || !d.name) {
|
|
97
|
+
failLoad(yamlPath, "name", "non-empty string", d.name, "descriptor must have a top-level 'name' string");
|
|
98
|
+
}
|
|
99
|
+
const bridge = d.name;
|
|
100
|
+
if (d.kind !== "file") {
|
|
101
|
+
fail(bridge, yamlPath, "kind", "'file'", d.kind, "YAML descriptors have kind: file. API plugins use a TypeScript code plugin — see docs/bridges.md §6");
|
|
102
|
+
}
|
|
103
|
+
const versionRaw = d.version;
|
|
104
|
+
const version = typeof versionRaw === "number" ? versionRaw : versionRaw === undefined ? 1 : NaN;
|
|
105
|
+
if (!Number.isFinite(version)) {
|
|
106
|
+
fail(bridge, yamlPath, "version", "number", versionRaw, "version must be a number; defaulting to 1 if omitted is supported");
|
|
107
|
+
}
|
|
108
|
+
const descriptor = {
|
|
109
|
+
name: bridge,
|
|
110
|
+
version,
|
|
111
|
+
kind: "file",
|
|
112
|
+
};
|
|
113
|
+
if (typeof d.description === "string")
|
|
114
|
+
descriptor.description = d.description;
|
|
115
|
+
if (d.detect && typeof d.detect === "object") {
|
|
116
|
+
const det = d.detect;
|
|
117
|
+
const detect = {};
|
|
118
|
+
if (Array.isArray(det.anyExists)) {
|
|
119
|
+
detect.anyExists = det.anyExists.filter((x) => typeof x === "string");
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(det.allExist)) {
|
|
122
|
+
detect.allExist = det.allExist.filter((x) => typeof x === "string");
|
|
123
|
+
}
|
|
124
|
+
descriptor.detect = detect;
|
|
125
|
+
}
|
|
126
|
+
if (d.import && typeof d.import === "object") {
|
|
127
|
+
const imp = d.import;
|
|
128
|
+
if (!Array.isArray(imp.sources) || imp.sources.length === 0) {
|
|
129
|
+
fail(bridge, yamlPath, "import.sources", "non-empty array", imp.sources, "import must have a 'sources' array with at least one entry");
|
|
130
|
+
}
|
|
131
|
+
descriptor.import = {
|
|
132
|
+
sources: imp.sources.map((s, i) => normalizeSourceTarget(bridge, yamlPath, `import.sources[${i}]`, s)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (d.export && typeof d.export === "object") {
|
|
136
|
+
const exp = d.export;
|
|
137
|
+
if (!Array.isArray(exp.targets) || exp.targets.length === 0) {
|
|
138
|
+
fail(bridge, yamlPath, "export.targets", "non-empty array", exp.targets, "export must have a 'targets' array with at least one entry");
|
|
139
|
+
}
|
|
140
|
+
descriptor.export = {
|
|
141
|
+
targets: exp.targets.map((t, i) => normalizeSourceTarget(bridge, yamlPath, `export.targets[${i}]`, t)),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (!descriptor.import && !descriptor.export) {
|
|
145
|
+
fail(bridge, yamlPath, "(root)", "import or export block", "neither", "descriptor must define at least one of `import` or `export`");
|
|
146
|
+
}
|
|
147
|
+
return descriptor;
|
|
148
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge scaffolding — emits a working starter for a new bridge.
|
|
3
|
+
*
|
|
4
|
+
* From FLAIR-BRIDGES.md §2: "Scaffold & test in-tree. `flair bridge
|
|
5
|
+
* scaffold <name>` emits a working starter; `flair bridge test <name>`
|
|
6
|
+
* runs a round-trip diff." This file handles the `scaffold` half.
|
|
7
|
+
*
|
|
8
|
+
* Two shapes supported:
|
|
9
|
+
* - --file: YAML descriptor at `.flair-bridge/<name>.yaml` + fixture
|
|
10
|
+
* - --api: TS code plugin at `flair-bridge-<name>/` + package.json + fixture
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fsp } from "node:fs";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
const NAME_RE = /^[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
15
|
+
async function exists(path) {
|
|
16
|
+
try {
|
|
17
|
+
await fsp.stat(path);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function writeIfAbsent(path, content, force) {
|
|
25
|
+
if (!force && (await exists(path)))
|
|
26
|
+
return { wrote: false };
|
|
27
|
+
await fsp.mkdir(dirname(path), { recursive: true });
|
|
28
|
+
await fsp.writeFile(path, content, "utf-8");
|
|
29
|
+
return { wrote: true };
|
|
30
|
+
}
|
|
31
|
+
function fileDescriptorTemplate(name) {
|
|
32
|
+
return `# .flair-bridge/${name}.yaml
|
|
33
|
+
#
|
|
34
|
+
# Generated by \`flair bridge scaffold ${name} --file\`.
|
|
35
|
+
# Edit to match your foreign system, then run \`flair bridge test ${name}\`.
|
|
36
|
+
#
|
|
37
|
+
# Spec: see FLAIR-BRIDGES.md §4 (memory schema) and §5 (YAML shape).
|
|
38
|
+
name: ${name}
|
|
39
|
+
version: 1
|
|
40
|
+
kind: file
|
|
41
|
+
# description shown in 'flair bridge list':
|
|
42
|
+
description: "TODO — one-line description of what this bridge does"
|
|
43
|
+
|
|
44
|
+
# 'detect' lets auto-discovery confirm the foreign system is present.
|
|
45
|
+
# Use 'anyExists' (any one path matching is enough) or 'allExist'.
|
|
46
|
+
detect:
|
|
47
|
+
anyExists:
|
|
48
|
+
- ".example/AGENTS.md"
|
|
49
|
+
|
|
50
|
+
# Import: foreign → Flair memories.
|
|
51
|
+
import:
|
|
52
|
+
sources:
|
|
53
|
+
- path: ".example/memory/lessons.jsonl"
|
|
54
|
+
format: jsonl # jsonl | json | yaml | markdown-frontmatter
|
|
55
|
+
map:
|
|
56
|
+
# JSONPath subset: $.field, $.nested.field, $.array[*]
|
|
57
|
+
content: "$.claim"
|
|
58
|
+
subject: "$.topic"
|
|
59
|
+
tags: "$.tags"
|
|
60
|
+
foreignId: "$.id"
|
|
61
|
+
durability: "persistent"
|
|
62
|
+
source: "${name}/lessons"
|
|
63
|
+
|
|
64
|
+
# Export: Flair memories → foreign. Optional. Drop this block if read-only.
|
|
65
|
+
export:
|
|
66
|
+
targets:
|
|
67
|
+
- path: ".example/memory/lessons.jsonl"
|
|
68
|
+
format: jsonl
|
|
69
|
+
# 'when' is a boolean expression over BridgeMemory fields.
|
|
70
|
+
# Omit for "always export".
|
|
71
|
+
when: "durability in ['persistent', 'permanent']"
|
|
72
|
+
map:
|
|
73
|
+
# Right-hand side is a BridgeMemory field (or expression).
|
|
74
|
+
id: "foreignId ?? id"
|
|
75
|
+
claim: "content"
|
|
76
|
+
topic: "subject"
|
|
77
|
+
tags: "tags"
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
function fileFixtureTemplate(name) {
|
|
81
|
+
// JSONL fixture matching the default template's 'sources[0].path'.
|
|
82
|
+
return [
|
|
83
|
+
JSON.stringify({ id: "lesson-1", claim: "Always run tests before pushing.", topic: "engineering", tags: ["process", "ci"] }),
|
|
84
|
+
JSON.stringify({ id: "lesson-2", claim: "Name workarounds as workarounds.", topic: "communication", tags: ["writing"] }),
|
|
85
|
+
"",
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
function apiIndexTemplate(name) {
|
|
89
|
+
return `/**
|
|
90
|
+
* flair-bridge-${name}
|
|
91
|
+
*
|
|
92
|
+
* Generated by \`flair bridge scaffold ${name} --api\`.
|
|
93
|
+
* Spec: see FLAIR-BRIDGES.md §4 (memory schema) and §6 (code plugin shape).
|
|
94
|
+
*
|
|
95
|
+
* After editing, run:
|
|
96
|
+
* flair bridge test ${name}
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
import type {
|
|
100
|
+
MemoryBridge,
|
|
101
|
+
BridgeMemory,
|
|
102
|
+
BridgeContext,
|
|
103
|
+
} from "@tpsdev-ai/flair";
|
|
104
|
+
|
|
105
|
+
export const bridge: MemoryBridge = {
|
|
106
|
+
name: "${name}",
|
|
107
|
+
version: 1,
|
|
108
|
+
kind: "api",
|
|
109
|
+
|
|
110
|
+
options: {
|
|
111
|
+
apiKey: { env: "${name.toUpperCase().replace(/-/g, "_")}_API_KEY", required: true, description: "API token" },
|
|
112
|
+
userId: { required: true, description: "Account or user identifier in the foreign system" },
|
|
113
|
+
baseUrl: { default: "https://api.example.com", description: "Foreign API base URL" },
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
async *import(opts, ctx: BridgeContext): AsyncIterable<BridgeMemory> {
|
|
117
|
+
ctx.log.info("starting import", { user: String(opts.userId) });
|
|
118
|
+
const res = await ctx.fetch(\`\${String(opts.baseUrl)}/v1/memories?user=\${String(opts.userId)}\`, {
|
|
119
|
+
headers: { Authorization: \`Bearer \${String(opts.apiKey)}\` },
|
|
120
|
+
});
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
throw new Error(\`foreign API returned \${res.status}: \${await res.text()}\`);
|
|
123
|
+
}
|
|
124
|
+
const payload = await res.json() as { results?: Array<Record<string, unknown>> };
|
|
125
|
+
for (const m of payload.results ?? []) {
|
|
126
|
+
yield {
|
|
127
|
+
foreignId: String(m.id ?? ""),
|
|
128
|
+
content: String(m.content ?? ""),
|
|
129
|
+
createdAt: typeof m.created_at === "string" ? m.created_at : undefined,
|
|
130
|
+
tags: Array.isArray(m.tags) ? m.tags.map(String) : undefined,
|
|
131
|
+
source: "${name}",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async export(memories, opts, ctx): Promise<void> {
|
|
137
|
+
ctx.log.info("starting export", { user: String(opts.userId) });
|
|
138
|
+
for await (const m of memories) {
|
|
139
|
+
await ctx.fetch(\`\${String(opts.baseUrl)}/v1/memories\`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: {
|
|
142
|
+
Authorization: \`Bearer \${String(opts.apiKey)}\`,
|
|
143
|
+
"content-type": "application/json",
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
user: String(opts.userId),
|
|
147
|
+
content: m.content,
|
|
148
|
+
tags: m.tags ?? [],
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export default bridge;
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
function apiPackageJsonTemplate(name) {
|
|
159
|
+
const pkg = {
|
|
160
|
+
name: `flair-bridge-${name}`,
|
|
161
|
+
version: "0.1.0",
|
|
162
|
+
type: "module",
|
|
163
|
+
description: `Flair bridge for ${name}`,
|
|
164
|
+
main: "index.js",
|
|
165
|
+
types: "index.d.ts",
|
|
166
|
+
scripts: {
|
|
167
|
+
build: "tsc",
|
|
168
|
+
},
|
|
169
|
+
flair: { kind: "api", version: 1 },
|
|
170
|
+
peerDependencies: {
|
|
171
|
+
"@tpsdev-ai/flair": ">=1.0.0",
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
return JSON.stringify(pkg, null, 2) + "\n";
|
|
175
|
+
}
|
|
176
|
+
function apiFixtureTemplate(name) {
|
|
177
|
+
return JSON.stringify({
|
|
178
|
+
description: `Mock responses for flair-bridge-${name}. Used by 'flair bridge test ${name}'.`,
|
|
179
|
+
import: {
|
|
180
|
+
"GET /v1/memories?user=example": {
|
|
181
|
+
status: 200,
|
|
182
|
+
body: {
|
|
183
|
+
results: [
|
|
184
|
+
{ id: "m-1", content: "First imported memory.", tags: ["example"] },
|
|
185
|
+
{ id: "m-2", content: "Second imported memory.", tags: ["example"] },
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
export: {
|
|
191
|
+
"POST /v1/memories": { status: 201, body: { ok: true } },
|
|
192
|
+
},
|
|
193
|
+
}, null, 2) + "\n";
|
|
194
|
+
}
|
|
195
|
+
export async function scaffold(opts) {
|
|
196
|
+
if (!NAME_RE.test(opts.name)) {
|
|
197
|
+
throw new Error(`invalid bridge name "${opts.name}" — must match ${NAME_RE} (lowercase, digits, hyphens; start with letter).`);
|
|
198
|
+
}
|
|
199
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
200
|
+
const force = opts.force ?? false;
|
|
201
|
+
const createdFiles = [];
|
|
202
|
+
const skippedFiles = [];
|
|
203
|
+
const track = async (path, content) => {
|
|
204
|
+
const { wrote } = await writeIfAbsent(path, content, force);
|
|
205
|
+
if (wrote)
|
|
206
|
+
createdFiles.push(path);
|
|
207
|
+
else
|
|
208
|
+
skippedFiles.push(path);
|
|
209
|
+
};
|
|
210
|
+
if (opts.kind === "file") {
|
|
211
|
+
await track(join(cwd, ".flair-bridge", `${opts.name}.yaml`), fileDescriptorTemplate(opts.name));
|
|
212
|
+
await track(join(cwd, ".flair-bridge", "fixtures", `${opts.name}.fixture.jsonl`), fileFixtureTemplate(opts.name));
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const pkgDir = join(cwd, `flair-bridge-${opts.name}`);
|
|
216
|
+
await track(join(pkgDir, "index.ts"), apiIndexTemplate(opts.name));
|
|
217
|
+
await track(join(pkgDir, "package.json"), apiPackageJsonTemplate(opts.name));
|
|
218
|
+
await track(join(pkgDir, "fixtures", `${opts.name}.mock.json`), apiFixtureTemplate(opts.name));
|
|
219
|
+
}
|
|
220
|
+
const summary = createdFiles.length === 0
|
|
221
|
+
? `nothing created — all files already exist. Pass --force to overwrite.`
|
|
222
|
+
: `scaffolded ${opts.kind} bridge "${opts.name}". Next: edit the descriptor, then run \`flair bridge test ${opts.name}\`.`;
|
|
223
|
+
return { createdFiles, skippedFiles, summary };
|
|
224
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the Flair memory bridge plugin system.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the contract in specs/FLAIR-BRIDGES.md. These types are the
|
|
5
|
+
* public surface a bridge author (human or agent) targets.
|
|
6
|
+
*
|
|
7
|
+
* IMPORTANT: the YAML bridge format (shape A) also conforms to these types
|
|
8
|
+
* after parsing — the runtime normalizes YAML descriptors into the same
|
|
9
|
+
* shape a code plugin (shape B) exports.
|
|
10
|
+
*/
|
|
11
|
+
// Fields a bridge MUST NOT set — computed by Flair on ingest.
|
|
12
|
+
export const FLAIR_RESERVED_FIELDS = [
|
|
13
|
+
"contentHash",
|
|
14
|
+
"embedding",
|
|
15
|
+
"embeddingModel",
|
|
16
|
+
"retrievalCount",
|
|
17
|
+
"lastRetrieved",
|
|
18
|
+
"promotionStatus",
|
|
19
|
+
"_safetyFlags",
|
|
20
|
+
"createdBy",
|
|
21
|
+
"updatedBy",
|
|
22
|
+
"archivedBy",
|
|
23
|
+
];
|
|
24
|
+
export class BridgeRuntimeError extends Error {
|
|
25
|
+
detail;
|
|
26
|
+
constructor(detail) {
|
|
27
|
+
super(`${detail.bridge}:${detail.op} — ${detail.hint}`);
|
|
28
|
+
this.detail = detail;
|
|
29
|
+
}
|
|
30
|
+
}
|