@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
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
|
+
}
|