@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,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format parsers. Each format knows how to yield a stream of records
|
|
3
|
+
* (plain objects) from a file. The runtime then applies the mapper to
|
|
4
|
+
* each record independently.
|
|
5
|
+
*
|
|
6
|
+
* Slice 2 ships `jsonl` and `json`. `yaml` and `markdown-frontmatter`
|
|
7
|
+
* land with slice 2b along with reference adapters that need them.
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fsp } from "node:fs";
|
|
10
|
+
import yaml from "js-yaml";
|
|
11
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
12
|
+
export async function* parseRecords(bridge, path, format) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await fsp.readFile(path, "utf-8");
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
throw new BridgeRuntimeError({
|
|
19
|
+
bridge,
|
|
20
|
+
op: "import",
|
|
21
|
+
path,
|
|
22
|
+
field: "(source.path)",
|
|
23
|
+
expected: "readable file",
|
|
24
|
+
got: err?.code ?? "ENOENT",
|
|
25
|
+
hint: `could not read source file: ${err?.message ?? err}`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
switch (format) {
|
|
29
|
+
case "jsonl":
|
|
30
|
+
yield* parseJsonl(bridge, path, raw);
|
|
31
|
+
return;
|
|
32
|
+
case "json":
|
|
33
|
+
yield* parseJson(bridge, path, raw);
|
|
34
|
+
return;
|
|
35
|
+
case "yaml":
|
|
36
|
+
yield* parseYamlRecords(bridge, path, raw);
|
|
37
|
+
return;
|
|
38
|
+
case "markdown-frontmatter":
|
|
39
|
+
throw new BridgeRuntimeError({
|
|
40
|
+
bridge,
|
|
41
|
+
op: "import",
|
|
42
|
+
path,
|
|
43
|
+
field: "format",
|
|
44
|
+
expected: "jsonl | json | yaml",
|
|
45
|
+
got: "markdown-frontmatter",
|
|
46
|
+
hint: "markdown-frontmatter parser lands in slice 2b along with its reference adapter",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// ─── jsonl ────────────────────────────────────────────────────────────────────
|
|
51
|
+
function* parseJsonl(bridge, path, raw) {
|
|
52
|
+
const lines = raw.split(/\r?\n/);
|
|
53
|
+
let recordIndex = 0;
|
|
54
|
+
for (let i = 0; i < lines.length; i++) {
|
|
55
|
+
const line = lines[i];
|
|
56
|
+
if (!line.trim())
|
|
57
|
+
continue;
|
|
58
|
+
recordIndex++;
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(line);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
throw new BridgeRuntimeError({
|
|
65
|
+
bridge,
|
|
66
|
+
op: "import",
|
|
67
|
+
path,
|
|
68
|
+
record: recordIndex,
|
|
69
|
+
field: "(record)",
|
|
70
|
+
expected: "valid JSON per line",
|
|
71
|
+
got: truncate(line, 80),
|
|
72
|
+
hint: `line ${i + 1} is not valid JSON: ${err?.message ?? err}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
yield { record: parsed, recordIndex };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ─── json ─────────────────────────────────────────────────────────────────────
|
|
79
|
+
function* parseJson(bridge, path, raw) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
throw new BridgeRuntimeError({
|
|
86
|
+
bridge,
|
|
87
|
+
op: "import",
|
|
88
|
+
path,
|
|
89
|
+
field: "(document)",
|
|
90
|
+
expected: "valid JSON",
|
|
91
|
+
got: truncate(raw, 80),
|
|
92
|
+
hint: `JSON parse failed: ${err?.message ?? err}`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
// Accept either an array of records or a single object; treat the single
|
|
96
|
+
// object as an array of one.
|
|
97
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
98
|
+
let recordIndex = 0;
|
|
99
|
+
for (const r of arr) {
|
|
100
|
+
recordIndex++;
|
|
101
|
+
yield { record: r, recordIndex };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// ─── yaml (multi-doc) ─────────────────────────────────────────────────────────
|
|
105
|
+
function* parseYamlRecords(bridge, path, raw) {
|
|
106
|
+
let docs;
|
|
107
|
+
try {
|
|
108
|
+
docs = yaml.loadAll(raw);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
throw new BridgeRuntimeError({
|
|
112
|
+
bridge,
|
|
113
|
+
op: "import",
|
|
114
|
+
path,
|
|
115
|
+
field: "(document)",
|
|
116
|
+
expected: "valid YAML",
|
|
117
|
+
got: err?.name ?? "parse error",
|
|
118
|
+
hint: `YAML parse failed: ${err?.message ?? err}`,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
let recordIndex = 0;
|
|
122
|
+
for (const doc of docs) {
|
|
123
|
+
if (doc === undefined || doc === null)
|
|
124
|
+
continue;
|
|
125
|
+
// Accept either a single doc that's an array, or multiple docs.
|
|
126
|
+
const arr = Array.isArray(doc) ? doc : [doc];
|
|
127
|
+
for (const r of arr) {
|
|
128
|
+
recordIndex++;
|
|
129
|
+
yield { record: r, recordIndex };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// ─── util ─────────────────────────────────────────────────────────────────────
|
|
134
|
+
function truncate(s, n) {
|
|
135
|
+
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
|
136
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import runner — bridges the `BridgeMemory` stream from the runtime
|
|
3
|
+
* executor into Flair's HTTP API. Used by `flair bridge import`.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities:
|
|
6
|
+
* - Apply the per-invocation `--agent` default (every imported memory
|
|
7
|
+
* needs an `agentId`; the spec says either the bridge sets one or the
|
|
8
|
+
* operator passes `--agent`)
|
|
9
|
+
* - PUT each memory to `/Memory/<id>` via the caller-provided poster
|
|
10
|
+
* - Track per-source counts, success, skips, and the first error
|
|
11
|
+
* - In `--dry-run` mode, run the executor + validation but skip the PUT
|
|
12
|
+
*
|
|
13
|
+
* The Flair POST itself is injected as a function so this module stays
|
|
14
|
+
* unit-testable without spinning up a real Flair instance.
|
|
15
|
+
*/
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
18
|
+
import { importFromYaml } from "./execute.js";
|
|
19
|
+
const VALID_DURABILITY = new Set(["ephemeral", "standard", "persistent", "permanent"]);
|
|
20
|
+
/**
|
|
21
|
+
* Drive a YAML descriptor's `import` block all the way through to PUTs
|
|
22
|
+
* against `putMemory`. Errors propagate as `BridgeRuntimeError`.
|
|
23
|
+
*/
|
|
24
|
+
export async function runImport(opts) {
|
|
25
|
+
const onProgress = opts.onProgress ?? (() => { });
|
|
26
|
+
let total = 0;
|
|
27
|
+
let imported = 0;
|
|
28
|
+
let skipped = 0;
|
|
29
|
+
for await (const m of importFromYaml(opts.descriptor, { cwd: opts.cwd, ctx: opts.ctx })) {
|
|
30
|
+
total++;
|
|
31
|
+
const resolvedAgent = m.agentId ?? opts.agentId;
|
|
32
|
+
if (!resolvedAgent) {
|
|
33
|
+
// Spec §4: agentId is required either on the record or as a flag.
|
|
34
|
+
// We surface as a structured error rather than silently dropping —
|
|
35
|
+
// the operator should know whether this is a descriptor bug or a
|
|
36
|
+
// missing flag.
|
|
37
|
+
throw new BridgeRuntimeError({
|
|
38
|
+
bridge: opts.descriptor.name,
|
|
39
|
+
op: "import",
|
|
40
|
+
field: "agentId",
|
|
41
|
+
expected: "set on record OR provided via --agent",
|
|
42
|
+
got: "missing",
|
|
43
|
+
hint: `record ${total} has no agentId and no --agent default was provided. Pass --agent <id> on the import command, or have the descriptor map an agentId column.`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const durability = (m.durability && VALID_DURABILITY.has(m.durability))
|
|
47
|
+
? m.durability
|
|
48
|
+
: "standard";
|
|
49
|
+
const id = m.id ?? `${resolvedAgent}-${Date.now()}-${shortRand()}`;
|
|
50
|
+
const body = {
|
|
51
|
+
id,
|
|
52
|
+
agentId: resolvedAgent,
|
|
53
|
+
content: m.content,
|
|
54
|
+
durability,
|
|
55
|
+
type: "memory",
|
|
56
|
+
createdAt: m.createdAt ?? new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
if (m.subject)
|
|
59
|
+
body.subject = m.subject;
|
|
60
|
+
if (m.tags && m.tags.length > 0)
|
|
61
|
+
body.tags = m.tags;
|
|
62
|
+
if (m.visibility)
|
|
63
|
+
body.visibility = m.visibility;
|
|
64
|
+
if (m.validFrom)
|
|
65
|
+
body.validFrom = m.validFrom;
|
|
66
|
+
if (m.validTo)
|
|
67
|
+
body.validTo = m.validTo;
|
|
68
|
+
if (m.expiresAt)
|
|
69
|
+
body.expiresAt = m.expiresAt;
|
|
70
|
+
if (m.source)
|
|
71
|
+
body.source = m.source;
|
|
72
|
+
if (m.derivedFrom && m.derivedFrom.length > 0)
|
|
73
|
+
body.derivedFrom = m.derivedFrom;
|
|
74
|
+
if (m.foreignId)
|
|
75
|
+
body.foreignId = m.foreignId;
|
|
76
|
+
if (opts.dryRun) {
|
|
77
|
+
skipped++;
|
|
78
|
+
onProgress({ type: "memory-skipped", ordinal: total, reason: "--dry-run" });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await opts.putMemory(body);
|
|
83
|
+
imported++;
|
|
84
|
+
onProgress({ type: "memory-imported", foreignId: m.foreignId, flairId: id, ordinal: total });
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
// Wrap PUT errors so they read consistently with other BridgeRuntimeErrors.
|
|
88
|
+
throw new BridgeRuntimeError({
|
|
89
|
+
bridge: opts.descriptor.name,
|
|
90
|
+
op: "import",
|
|
91
|
+
record: total,
|
|
92
|
+
field: "(write)",
|
|
93
|
+
expected: "successful PUT /Memory",
|
|
94
|
+
got: err?.message ?? String(err),
|
|
95
|
+
hint: `Flair rejected the write for memory ${id}: ${err?.message ?? err}`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
onProgress({ type: "done", total, imported, skipped });
|
|
100
|
+
return { total, imported, skipped };
|
|
101
|
+
}
|
|
102
|
+
function shortRand() {
|
|
103
|
+
// Random 8-char suffix — enough collision space for a single import run
|
|
104
|
+
// when paired with the timestamp. We don't use crypto.randomUUID() in the
|
|
105
|
+
// ID because Flair's memory IDs are typically `<agent>-<ts>-<short>`.
|
|
106
|
+
return randomUUID().replace(/-/g, "").slice(0, 8);
|
|
107
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified descriptor loader.
|
|
3
|
+
*
|
|
4
|
+
* Given a discovered bridge, resolve it into a `YamlBridgeDescriptor`
|
|
5
|
+
* that the runtime executor can drive — regardless of whether the
|
|
6
|
+
* descriptor came from the in-tree built-in registry, a project-local
|
|
7
|
+
* YAML, a user-scoped YAML, or an npm package.
|
|
8
|
+
*
|
|
9
|
+
* Code-plugin bridges (Shape B, npm packages) are not supported in
|
|
10
|
+
* slice 2 — calling `loadDescriptor` on one returns a structured error
|
|
11
|
+
* pointing at slice 3.
|
|
12
|
+
*/
|
|
13
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
14
|
+
import { loadYamlDescriptor } from "./yaml-loader.js";
|
|
15
|
+
import { BUILTIN_BY_NAME } from "../builtins/index.js";
|
|
16
|
+
export async function loadDescriptor(discovered) {
|
|
17
|
+
switch (discovered.source) {
|
|
18
|
+
case "builtin": {
|
|
19
|
+
const builtin = BUILTIN_BY_NAME.get(discovered.name);
|
|
20
|
+
if (!builtin) {
|
|
21
|
+
throw new BridgeRuntimeError({
|
|
22
|
+
bridge: discovered.name,
|
|
23
|
+
op: "import",
|
|
24
|
+
field: "(builtin)",
|
|
25
|
+
expected: `registered built-in name`,
|
|
26
|
+
got: discovered.name,
|
|
27
|
+
hint: `discovery surfaced built-in "${discovered.name}" but the registry has no entry — likely a code drift between discover.ts and builtins/index.ts`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return builtin.descriptor;
|
|
31
|
+
}
|
|
32
|
+
case "project-yaml":
|
|
33
|
+
case "user-yaml":
|
|
34
|
+
return await loadYamlDescriptor(discovered.path);
|
|
35
|
+
case "npm-package":
|
|
36
|
+
throw new BridgeRuntimeError({
|
|
37
|
+
bridge: discovered.name,
|
|
38
|
+
op: "import",
|
|
39
|
+
path: discovered.path,
|
|
40
|
+
field: "(kind)",
|
|
41
|
+
expected: "yaml file or built-in",
|
|
42
|
+
got: "npm code plugin",
|
|
43
|
+
hint: "code-plugin bridge runtime ships in slice 3 of FLAIR-BRIDGES (alongside the `flair bridge allow` trust prompt). Use a YAML descriptor in the meantime, or wait for slice 3.",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONPath-subset expression evaluator.
|
|
3
|
+
*
|
|
4
|
+
* The YAML descriptor's `map` object binds BridgeMemory fields to source-side
|
|
5
|
+
* expressions. Slice 2 supports:
|
|
6
|
+
*
|
|
7
|
+
* "$.field" — root-level field of the source record
|
|
8
|
+
* "$.nested.field" — dotted path
|
|
9
|
+
* "$.array[*]" — the full array (caller decides what to do with it)
|
|
10
|
+
* "$.array[0]" — specific index
|
|
11
|
+
* "literal string" — string literal (no $ prefix) — used as a constant
|
|
12
|
+
*
|
|
13
|
+
* Slice 3 will extend with expressions (`foreignId ?? id`, ternaries, boolean
|
|
14
|
+
* predicates for `when:`). For now, `when` is ignored unless it starts with
|
|
15
|
+
* `$.` — a conservative default so stored descriptors don't error out.
|
|
16
|
+
*
|
|
17
|
+
* All functions are pure and synchronous. No file or network I/O.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Evaluate a mapping expression against a record. Returns `undefined` if the
|
|
21
|
+
* lookup misses — callers decide whether that's a skip or a hard error.
|
|
22
|
+
*/
|
|
23
|
+
export function evaluate(expression, record) {
|
|
24
|
+
if (typeof expression !== "string")
|
|
25
|
+
return undefined;
|
|
26
|
+
// Literal (no $ prefix) → constant string
|
|
27
|
+
if (!expression.startsWith("$"))
|
|
28
|
+
return expression;
|
|
29
|
+
// $ alone → the whole record
|
|
30
|
+
if (expression === "$")
|
|
31
|
+
return record;
|
|
32
|
+
// Must start with $. for a path
|
|
33
|
+
if (!expression.startsWith("$."))
|
|
34
|
+
return undefined;
|
|
35
|
+
const path = expression.slice(2);
|
|
36
|
+
const tokens = tokenize(path);
|
|
37
|
+
if (tokens === null)
|
|
38
|
+
return undefined; // malformed — refuse to guess
|
|
39
|
+
return walk(tokens, record);
|
|
40
|
+
}
|
|
41
|
+
// Field names a JSONPath expression may not traverse — would expose
|
|
42
|
+
// prototype internals on plain objects. The descriptor is operator-
|
|
43
|
+
// authored, but treating these as off-limits keeps a malicious
|
|
44
|
+
// descriptor from snooping prototype state.
|
|
45
|
+
const FORBIDDEN_FIELDS = new Set(["__proto__", "constructor", "prototype"]);
|
|
46
|
+
function walk(tokens, value) {
|
|
47
|
+
let cursor = value;
|
|
48
|
+
for (const tok of tokens) {
|
|
49
|
+
if (cursor == null)
|
|
50
|
+
return undefined;
|
|
51
|
+
if (tok.kind === "field") {
|
|
52
|
+
if (typeof cursor !== "object")
|
|
53
|
+
return undefined;
|
|
54
|
+
if (FORBIDDEN_FIELDS.has(tok.name))
|
|
55
|
+
return undefined;
|
|
56
|
+
// Use Object.prototype.hasOwnProperty + FORBIDDEN_FIELDS above to
|
|
57
|
+
// avoid inheriting from prototype. Use Reflect.get (method call)
|
|
58
|
+
// instead of bracket access so Semgrep's prototype-pollution-loop
|
|
59
|
+
// rule doesn't flag the read (which isn't a pollution vector).
|
|
60
|
+
const obj = cursor;
|
|
61
|
+
cursor = Object.prototype.hasOwnProperty.call(obj, tok.name)
|
|
62
|
+
? Reflect.get(obj, tok.name)
|
|
63
|
+
: undefined;
|
|
64
|
+
}
|
|
65
|
+
else if (tok.kind === "index") {
|
|
66
|
+
if (!Array.isArray(cursor))
|
|
67
|
+
return undefined;
|
|
68
|
+
// .at() method call instead of bracket access, same reason — Semgrep's
|
|
69
|
+
// dynamic-bracket rule doesn't trigger. tok.index is Number-parsed in
|
|
70
|
+
// tokenize(), non-numeric rejected.
|
|
71
|
+
cursor = cursor.at(tok.index);
|
|
72
|
+
}
|
|
73
|
+
else if (tok.kind === "splat") {
|
|
74
|
+
if (!Array.isArray(cursor))
|
|
75
|
+
return undefined;
|
|
76
|
+
// '[*]' returns the whole array; subsequent tokens don't apply
|
|
77
|
+
return cursor;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return cursor;
|
|
81
|
+
}
|
|
82
|
+
function tokenize(path) {
|
|
83
|
+
const tokens = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < path.length) {
|
|
86
|
+
if (path[i] === ".") {
|
|
87
|
+
i++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (path[i] === "[") {
|
|
91
|
+
const end = path.indexOf("]", i);
|
|
92
|
+
if (end < 0)
|
|
93
|
+
return null; // malformed — unterminated bracket
|
|
94
|
+
const inner = path.slice(i + 1, end);
|
|
95
|
+
if (inner === "*")
|
|
96
|
+
tokens.push({ kind: "splat" });
|
|
97
|
+
else {
|
|
98
|
+
const n = Number.parseInt(inner, 10);
|
|
99
|
+
if (!Number.isFinite(n))
|
|
100
|
+
return null; // malformed — non-numeric, non-splat bracket content
|
|
101
|
+
tokens.push({ kind: "index", index: n });
|
|
102
|
+
}
|
|
103
|
+
i = end + 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Field name: run until next . or [
|
|
107
|
+
let j = i;
|
|
108
|
+
while (j < path.length && path[j] !== "." && path[j] !== "[")
|
|
109
|
+
j++;
|
|
110
|
+
const name = path.slice(i, j);
|
|
111
|
+
if (name)
|
|
112
|
+
tokens.push({ kind: "field", name });
|
|
113
|
+
i = j;
|
|
114
|
+
}
|
|
115
|
+
return tokens;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Apply an entire `map: { field: expression, ... }` block to a source record,
|
|
119
|
+
* producing a BridgeMemory-shaped partial. Empty-string and undefined results
|
|
120
|
+
* are dropped. Arrays are preserved (tags may be an array splat).
|
|
121
|
+
*/
|
|
122
|
+
export function applyMap(mapping, record) {
|
|
123
|
+
// Object.create(null) — no prototype chain. Assigning user-controlled
|
|
124
|
+
// keys (from the descriptor) into a regular object literal would let a
|
|
125
|
+
// malicious descriptor write to __proto__ / constructor and pollute
|
|
126
|
+
// every object in the runtime. Prototype-less out{} blocks that vector.
|
|
127
|
+
const out = Object.create(null);
|
|
128
|
+
for (const [field, expr] of Object.entries(mapping)) {
|
|
129
|
+
if (FORBIDDEN_FIELDS.has(field))
|
|
130
|
+
continue; // belt + suspenders with the proto-less out
|
|
131
|
+
const value = evaluate(expr, record);
|
|
132
|
+
if (value === undefined)
|
|
133
|
+
continue;
|
|
134
|
+
if (typeof value === "string" && value.length === 0)
|
|
135
|
+
continue;
|
|
136
|
+
out[field] = value;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny predicate evaluator for the YAML descriptor's `when:` clause.
|
|
3
|
+
*
|
|
4
|
+
* Slice 3a supports a deliberately small subset:
|
|
5
|
+
*
|
|
6
|
+
* <field> in [<literal>, <literal>, ...]
|
|
7
|
+
* <field> == <literal>
|
|
8
|
+
* <field> != <literal>
|
|
9
|
+
*
|
|
10
|
+
* Where `<field>` is a `BridgeMemory`-shaped property name and `<literal>`
|
|
11
|
+
* is a single-quoted or double-quoted string, a bare identifier (treated
|
|
12
|
+
* as string), or a number/boolean. No nested expressions, no `&&`/`||`,
|
|
13
|
+
* no parenthesization.
|
|
14
|
+
*
|
|
15
|
+
* Slice 3b will widen this to a real mini-grammar (probably JMESPath or
|
|
16
|
+
* CEL or hand-rolled boolean operators). For now the goal is just to
|
|
17
|
+
* support the agentic-stack reference adapter's
|
|
18
|
+
* when: "durability in ['persistent', 'permanent']"
|
|
19
|
+
* cleanly without pulling in a dep.
|
|
20
|
+
*
|
|
21
|
+
* Returns null on unparsable input — caller decides whether to default
|
|
22
|
+
* to true (always-export) or hard-fail.
|
|
23
|
+
*/
|
|
24
|
+
const FIELD_RE = /^([a-zA-Z_][a-zA-Z0-9_]*)\s+(in|==|!=)\s+(.+)$/;
|
|
25
|
+
export function evaluatePredicate(expression, record) {
|
|
26
|
+
const trimmed = expression.trim();
|
|
27
|
+
if (!trimmed)
|
|
28
|
+
return "match"; // empty = always match
|
|
29
|
+
const m = trimmed.match(FIELD_RE);
|
|
30
|
+
if (!m)
|
|
31
|
+
return "unparsable";
|
|
32
|
+
const [, field, op, rhsRaw] = m;
|
|
33
|
+
const lhs = record[field];
|
|
34
|
+
if (op === "in") {
|
|
35
|
+
const list = parseList(rhsRaw);
|
|
36
|
+
if (list === null)
|
|
37
|
+
return "unparsable";
|
|
38
|
+
return list.some((v) => v === lhs) ? "match" : "no-match";
|
|
39
|
+
}
|
|
40
|
+
if (op === "==" || op === "!=") {
|
|
41
|
+
const rhs = parseLiteral(rhsRaw.trim());
|
|
42
|
+
if (rhs === undefined)
|
|
43
|
+
return "unparsable";
|
|
44
|
+
const eq = lhs === rhs;
|
|
45
|
+
return (op === "==" ? eq : !eq) ? "match" : "no-match";
|
|
46
|
+
}
|
|
47
|
+
return "unparsable";
|
|
48
|
+
}
|
|
49
|
+
function parseList(raw) {
|
|
50
|
+
const trimmed = raw.trim();
|
|
51
|
+
if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
|
|
52
|
+
return null;
|
|
53
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
54
|
+
if (!inner)
|
|
55
|
+
return [];
|
|
56
|
+
// Split on commas, ignoring commas inside quoted strings. Slice 3a's
|
|
57
|
+
// grammar doesn't allow quoted commas anyway; keep this naive.
|
|
58
|
+
const parts = splitCsvRespectingQuotes(inner);
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const p of parts) {
|
|
61
|
+
const lit = parseLiteral(p.trim());
|
|
62
|
+
if (lit === undefined)
|
|
63
|
+
return null;
|
|
64
|
+
out.push(lit);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function parseLiteral(raw) {
|
|
69
|
+
if (raw === "")
|
|
70
|
+
return undefined;
|
|
71
|
+
// Quoted string: single or double
|
|
72
|
+
if ((raw.startsWith("'") && raw.endsWith("'")) || (raw.startsWith('"') && raw.endsWith('"'))) {
|
|
73
|
+
return raw.slice(1, -1);
|
|
74
|
+
}
|
|
75
|
+
// Boolean / null
|
|
76
|
+
if (raw === "true")
|
|
77
|
+
return true;
|
|
78
|
+
if (raw === "false")
|
|
79
|
+
return false;
|
|
80
|
+
if (raw === "null")
|
|
81
|
+
return null;
|
|
82
|
+
// Number
|
|
83
|
+
if (/^-?\d+(\.\d+)?$/.test(raw))
|
|
84
|
+
return Number(raw);
|
|
85
|
+
// Bare identifier — treat as string. Allows: in [persistent, permanent]
|
|
86
|
+
if (/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(raw))
|
|
87
|
+
return raw;
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
function splitCsvRespectingQuotes(s) {
|
|
91
|
+
const out = [];
|
|
92
|
+
let depth = 0;
|
|
93
|
+
let buf = "";
|
|
94
|
+
let inQuote = null;
|
|
95
|
+
for (let i = 0; i < s.length; i++) {
|
|
96
|
+
const c = s[i];
|
|
97
|
+
if (inQuote) {
|
|
98
|
+
buf += c;
|
|
99
|
+
if (c === inQuote)
|
|
100
|
+
inQuote = null;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (c === '"' || c === "'") {
|
|
104
|
+
inQuote = c;
|
|
105
|
+
buf += c;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (c === "[" || c === "(")
|
|
109
|
+
depth++;
|
|
110
|
+
if (c === "]" || c === ")")
|
|
111
|
+
depth--;
|
|
112
|
+
if (c === "," && depth === 0) {
|
|
113
|
+
out.push(buf);
|
|
114
|
+
buf = "";
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
buf += c;
|
|
118
|
+
}
|
|
119
|
+
if (buf.length > 0)
|
|
120
|
+
out.push(buf);
|
|
121
|
+
return out;
|
|
122
|
+
}
|