@tpsdev-ai/flair 0.5.3 → 0.6.0
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/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 +1019 -223
- package/dist/deploy.js +161 -0
- package/dist/resources/MemoryBootstrap.js +16 -6
- package/dist/resources/MemoryConsolidate.js +13 -4
- package/dist/resources/MemoryReflect.js +14 -5
- package/dist/resources/ObservationCenter.js +19 -0
- package/dist/resources/SemanticSearch.js +22 -11
- package/dist/resources/auth-middleware.js +6 -0
- package/dist/resources/health.js +443 -14
- package/package.json +7 -7
- package/ui/observation-center.html +91 -0
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
// Files that must be present in a Flair package for deployment.
|
|
7
|
+
// Mirrors the `files` array in package.json — keep in sync.
|
|
8
|
+
export const REQUIRED_PACKAGE_FILES = [
|
|
9
|
+
"dist",
|
|
10
|
+
"schemas",
|
|
11
|
+
"ui",
|
|
12
|
+
"config.yaml",
|
|
13
|
+
];
|
|
14
|
+
export function validateOptions(opts) {
|
|
15
|
+
const errors = [];
|
|
16
|
+
if (!opts.target) {
|
|
17
|
+
if (!opts.fabricOrg)
|
|
18
|
+
errors.push("--fabric-org required (or FABRIC_ORG env)");
|
|
19
|
+
if (!opts.fabricCluster)
|
|
20
|
+
errors.push("--fabric-cluster required (or FABRIC_CLUSTER env)");
|
|
21
|
+
}
|
|
22
|
+
const hasBasic = !!(opts.fabricUser && opts.fabricPassword);
|
|
23
|
+
const hasBearer = !!opts.fabricToken;
|
|
24
|
+
if (!hasBasic && !hasBearer) {
|
|
25
|
+
errors.push("credentials required: pass --fabric-user + --fabric-password " +
|
|
26
|
+
"(or FABRIC_USER / FABRIC_PASSWORD env), or --fabric-token " +
|
|
27
|
+
"(FABRIC_TOKEN env)");
|
|
28
|
+
}
|
|
29
|
+
return errors;
|
|
30
|
+
}
|
|
31
|
+
export function buildTargetUrl(opts) {
|
|
32
|
+
if (opts.target)
|
|
33
|
+
return opts.target;
|
|
34
|
+
return `https://${opts.fabricCluster}.${opts.fabricOrg}.harperfabric.com`;
|
|
35
|
+
}
|
|
36
|
+
export function resolvePackageRoot(override) {
|
|
37
|
+
if (override) {
|
|
38
|
+
const abs = resolve(override);
|
|
39
|
+
if (!existsSync(join(abs, "package.json"))) {
|
|
40
|
+
throw new Error(`No package.json at ${abs}`);
|
|
41
|
+
}
|
|
42
|
+
return abs;
|
|
43
|
+
}
|
|
44
|
+
// Walk up from this module's location — works when installed locally
|
|
45
|
+
// and when npx extracts the tarball to a tmpdir.
|
|
46
|
+
try {
|
|
47
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
let dir = here;
|
|
49
|
+
for (let i = 0; i < 8; i++) {
|
|
50
|
+
const pkgPath = join(dir, "package.json");
|
|
51
|
+
if (existsSync(pkgPath)) {
|
|
52
|
+
const json = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
53
|
+
if (json.name === "@tpsdev-ai/flair")
|
|
54
|
+
return dir;
|
|
55
|
+
}
|
|
56
|
+
const parent = dirname(dir);
|
|
57
|
+
if (parent === dir)
|
|
58
|
+
break;
|
|
59
|
+
dir = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* fall through */
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const req = createRequire(import.meta.url);
|
|
67
|
+
return dirname(req.resolve("@tpsdev-ai/flair/package.json"));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error("Could not locate @tpsdev-ai/flair package root. Try --package-root.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function validatePackageLayout(packageRoot) {
|
|
74
|
+
const missing = [];
|
|
75
|
+
for (const f of REQUIRED_PACKAGE_FILES) {
|
|
76
|
+
if (!existsSync(join(packageRoot, f)))
|
|
77
|
+
missing.push(f);
|
|
78
|
+
}
|
|
79
|
+
if (missing.length) {
|
|
80
|
+
throw new Error(`Flair package at ${packageRoot} is missing required entries: ` +
|
|
81
|
+
missing.join(", "));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function resolveHarperBin(packageRoot) {
|
|
85
|
+
const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
|
|
86
|
+
if (existsSync(local))
|
|
87
|
+
return local;
|
|
88
|
+
try {
|
|
89
|
+
const req = createRequire(join(packageRoot, "package.json"));
|
|
90
|
+
const mainPath = req.resolve("@harperfast/harper");
|
|
91
|
+
let dir = dirname(mainPath);
|
|
92
|
+
for (let i = 0; i < 6; i++) {
|
|
93
|
+
const candidate = join(dir, "dist/bin/harper.js");
|
|
94
|
+
if (existsSync(candidate))
|
|
95
|
+
return candidate;
|
|
96
|
+
const parent = dirname(dir);
|
|
97
|
+
if (parent === dir)
|
|
98
|
+
break;
|
|
99
|
+
dir = parent;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* fall through */
|
|
104
|
+
}
|
|
105
|
+
throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
|
|
106
|
+
"Flair deploy requires Harper to be installed alongside Flair.");
|
|
107
|
+
}
|
|
108
|
+
function spawnHarper(bin, args, cwd, env) {
|
|
109
|
+
return new Promise((resolveP, rejectP) => {
|
|
110
|
+
const p = spawn(process.execPath, [bin, ...args], {
|
|
111
|
+
cwd,
|
|
112
|
+
stdio: "inherit",
|
|
113
|
+
env,
|
|
114
|
+
});
|
|
115
|
+
p.on("error", rejectP);
|
|
116
|
+
p.on("exit", (code) => {
|
|
117
|
+
if (code === 0)
|
|
118
|
+
resolveP();
|
|
119
|
+
else
|
|
120
|
+
rejectP(new Error(`harper deploy exited with code ${code}`));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
export async function deploy(opts) {
|
|
125
|
+
const errors = validateOptions(opts);
|
|
126
|
+
if (errors.length) {
|
|
127
|
+
throw new Error(errors.join("\n"));
|
|
128
|
+
}
|
|
129
|
+
const packageRoot = resolvePackageRoot(opts.packageRoot);
|
|
130
|
+
validatePackageLayout(packageRoot);
|
|
131
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
132
|
+
const version = opts.version ?? pkg.version;
|
|
133
|
+
const project = opts.project ?? "flair";
|
|
134
|
+
const url = buildTargetUrl(opts);
|
|
135
|
+
if (opts.dryRun) {
|
|
136
|
+
return { url, project, version, packageRoot, dryRun: true };
|
|
137
|
+
}
|
|
138
|
+
if (opts.fabricToken && !(opts.fabricUser && opts.fabricPassword)) {
|
|
139
|
+
throw new Error("Bearer token auth (--fabric-token) is not yet supported — " +
|
|
140
|
+
"Harper's deploy_component CLI path only accepts Basic auth today. " +
|
|
141
|
+
"Pass --fabric-user + --fabric-password instead.");
|
|
142
|
+
}
|
|
143
|
+
const harperBin = resolveHarperBin(packageRoot);
|
|
144
|
+
const args = [
|
|
145
|
+
"deploy",
|
|
146
|
+
`target=${url}`,
|
|
147
|
+
`project=${project}`,
|
|
148
|
+
`restart=${opts.restart !== false}`,
|
|
149
|
+
`replicated=${opts.replicated !== false}`,
|
|
150
|
+
];
|
|
151
|
+
// Credentials go via env, not argv, so they don't appear in `ps` output
|
|
152
|
+
// for the lifetime of the Harper child process. Harper's cliOperations
|
|
153
|
+
// reads CLI_TARGET_USERNAME / CLI_TARGET_PASSWORD as env fallbacks.
|
|
154
|
+
const childEnv = {
|
|
155
|
+
...process.env,
|
|
156
|
+
CLI_TARGET_USERNAME: opts.fabricUser,
|
|
157
|
+
CLI_TARGET_PASSWORD: opts.fabricPassword,
|
|
158
|
+
};
|
|
159
|
+
await spawnHarper(harperBin, args, packageRoot, childEnv);
|
|
160
|
+
return { url, project, version, packageRoot, dryRun: false };
|
|
161
|
+
}
|
|
@@ -41,24 +41,34 @@ function formatMemory(m, supersedes) {
|
|
|
41
41
|
}
|
|
42
42
|
export class BootstrapMemories extends Resource {
|
|
43
43
|
async post(data, _context) {
|
|
44
|
-
const { agentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
|
|
44
|
+
const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
|
|
45
45
|
surface, // e.g., "tps-build", "tps-review", "cli-session"
|
|
46
46
|
subjects, // e.g., ["flair", "auth"] — entities to preload context for
|
|
47
47
|
} = data || {};
|
|
48
|
-
|
|
48
|
+
// Authenticated identity lives on getContext().request — `this.request` is
|
|
49
|
+
// NOT populated on Harper v5 Resources. Reading it returned undefined and
|
|
50
|
+
// the scope check was silently bypassed, letting a non-admin agent read
|
|
51
|
+
// another agent's soul + memories by passing the victim's id in the body.
|
|
52
|
+
const ctx = this.getContext?.();
|
|
53
|
+
const request = ctx?.request ?? ctx;
|
|
54
|
+
const authenticatedAgent = request?.tpsAgent ?? request?.headers?.get?.("x-tps-agent");
|
|
55
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true;
|
|
56
|
+
if (!bodyAgentId && !authenticatedAgent) {
|
|
49
57
|
return new Response(JSON.stringify({ error: "agentId required" }), {
|
|
50
58
|
status: 400,
|
|
51
59
|
headers: { "Content-Type": "application/json" },
|
|
52
60
|
});
|
|
53
61
|
}
|
|
54
|
-
|
|
55
|
-
const authenticatedAgent = this.request?.headers?.get?.("x-tps-agent");
|
|
56
|
-
const callerIsAdmin = this.request?.tpsAgentIsAdmin === true;
|
|
57
|
-
if (authenticatedAgent && !callerIsAdmin && agentId !== authenticatedAgent) {
|
|
62
|
+
if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
|
|
58
63
|
return new Response(JSON.stringify({
|
|
59
64
|
error: "forbidden: agentId must match authenticated agent",
|
|
60
65
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
61
66
|
}
|
|
67
|
+
// Pin scope to the authenticated agent for non-admins; admins can bootstrap
|
|
68
|
+
// any agentId (needed for setup scripts and UI impersonation flows).
|
|
69
|
+
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
70
|
+
? authenticatedAgent
|
|
71
|
+
: bodyAgentId;
|
|
62
72
|
const sections = {
|
|
63
73
|
soul: [],
|
|
64
74
|
skills: [],
|
|
@@ -57,13 +57,22 @@ function evaluate(record, now, olderThanMs) {
|
|
|
57
57
|
}
|
|
58
58
|
export class ConsolidateMemories extends Resource {
|
|
59
59
|
async post(data) {
|
|
60
|
-
const { agentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
|
|
61
|
-
|
|
60
|
+
const { agentId: bodyAgentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
|
|
61
|
+
// See SemanticSearch / MemoryBootstrap — `this.request` isn't populated on
|
|
62
|
+
// Harper v5 Resources, so the prior actorId check was silently bypassed
|
|
63
|
+
// and bob could enumerate alice's consolidation candidates (her memory records).
|
|
64
|
+
const ctx = this.getContext?.();
|
|
65
|
+
const request = ctx?.request ?? ctx;
|
|
66
|
+
const actorId = request?.tpsAgent;
|
|
67
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true
|
|
68
|
+
|| (actorId ? await isAdmin(actorId) : false);
|
|
69
|
+
if (!bodyAgentId && !actorId) {
|
|
62
70
|
return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
|
|
63
|
-
|
|
64
|
-
if (actorId &&
|
|
71
|
+
}
|
|
72
|
+
if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
|
|
65
73
|
return new Response(JSON.stringify({ error: "forbidden: can only consolidate own memories" }), { status: 403 });
|
|
66
74
|
}
|
|
75
|
+
const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
|
|
67
76
|
const olderThanMs = parseDuration(olderThan);
|
|
68
77
|
const now = Date.now();
|
|
69
78
|
const candidates = [];
|
|
@@ -30,14 +30,23 @@ const FOCUS_PROMPTS = {
|
|
|
30
30
|
};
|
|
31
31
|
export class ReflectMemories extends Resource {
|
|
32
32
|
async post(data) {
|
|
33
|
-
const { agentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
|
|
34
|
-
|
|
33
|
+
const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
|
|
34
|
+
// Authenticated identity comes from getContext().request, not this.request
|
|
35
|
+
// (see SemanticSearch / MemoryBootstrap for the same bug class). The prior
|
|
36
|
+
// check was silently bypassed — bob could reflect on alice's memories and
|
|
37
|
+
// mutate alice's records via the lastReflected patchRecordSilent below.
|
|
38
|
+
const ctx = this.getContext?.();
|
|
39
|
+
const request = ctx?.request ?? ctx;
|
|
40
|
+
const actorId = request?.tpsAgent;
|
|
41
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true
|
|
42
|
+
|| (actorId ? await isAdmin(actorId) : false);
|
|
43
|
+
if (!bodyAgentId && !actorId) {
|
|
35
44
|
return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
|
|
45
|
+
}
|
|
46
|
+
if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
|
|
39
47
|
return new Response(JSON.stringify({ error: "forbidden: can only reflect on own memories" }), { status: 403 });
|
|
40
48
|
}
|
|
49
|
+
const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
|
|
41
50
|
const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
|
|
42
51
|
const memories = [];
|
|
43
52
|
for await (const record of databases.flair.Memory.search()) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Resource } from "@harperfast/harper";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const HTML_PATH = resolve(HERE, "..", "..", "ui", "observation-center.html");
|
|
7
|
+
const HTML = readFileSync(HTML_PATH, "utf8");
|
|
8
|
+
/**
|
|
9
|
+
* GET /ObservationCenter — serves the read-only dashboard shell.
|
|
10
|
+
* The HTML handles its own Basic-auth prompt and polls authenticated JSON endpoints.
|
|
11
|
+
*/
|
|
12
|
+
export class ObservationCenter extends Resource {
|
|
13
|
+
async get() {
|
|
14
|
+
return new Response(HTML, {
|
|
15
|
+
status: 200,
|
|
16
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -46,13 +46,20 @@ function distanceToSimilarity(distance) {
|
|
|
46
46
|
const CANDIDATE_MULTIPLIER = 5;
|
|
47
47
|
export class SemanticSearch extends Resource {
|
|
48
48
|
async post(data) {
|
|
49
|
-
const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
|
|
50
|
+
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
51
|
+
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
52
|
+
// silently returned undefined and the defense-in-depth scope check below
|
|
53
|
+
// was bypassed, letting a non-admin agent read another agent's memories
|
|
54
|
+
// by putting the victim's id in the body.
|
|
55
|
+
const ctx = this.getContext?.();
|
|
56
|
+
const request = ctx?.request ?? ctx;
|
|
57
|
+
const authenticatedAgent = request?.tpsAgent ?? request?.headers?.get?.("x-tps-agent");
|
|
58
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true;
|
|
59
|
+
// Rate limiting — use authenticated agent ID, not client-supplied body
|
|
60
|
+
if (authenticatedAgent) {
|
|
54
61
|
const bucket = q && !queryEmbedding ? "embedding" : "general";
|
|
55
|
-
const rl = checkRateLimit(
|
|
62
|
+
const rl = checkRateLimit(authenticatedAgent, bucket);
|
|
56
63
|
if (!rl.allowed)
|
|
57
64
|
return rateLimitResponse(rl.retryAfterMs, "search");
|
|
58
65
|
}
|
|
@@ -61,15 +68,19 @@ export class SemanticSearch extends Resource {
|
|
|
61
68
|
: subject
|
|
62
69
|
? new Set([subject.toLowerCase()])
|
|
63
70
|
: null;
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (authenticatedAgent && !callerIsAdmin &&
|
|
71
|
+
// Enforce agentId = authenticated agent for non-admins. A mismatched body
|
|
72
|
+
// agentId is a cross-agent read attempt — reject outright. Admins can query
|
|
73
|
+
// any agentId (needed for bootstrap / consolidation scripts).
|
|
74
|
+
if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
|
|
68
75
|
return new Response(JSON.stringify({
|
|
69
76
|
error: "forbidden: agentId must match authenticated agent",
|
|
70
77
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
71
78
|
}
|
|
72
|
-
//
|
|
79
|
+
// Scope search to the authenticated agent (own + granted). For admins or
|
|
80
|
+
// unauthenticated internal calls, honor the body-supplied agentId.
|
|
81
|
+
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
82
|
+
? authenticatedAgent
|
|
83
|
+
: bodyAgentId;
|
|
73
84
|
const searchAgentIds = new Set();
|
|
74
85
|
if (agentId)
|
|
75
86
|
searchAgentIds.add(agentId);
|
|
@@ -150,6 +150,12 @@ server.http(async (request, nextLayer) => {
|
|
|
150
150
|
request.headers.set("x-tps-agent", "admin");
|
|
151
151
|
if (request.headers.asObject)
|
|
152
152
|
request.headers.asObject["x-tps-agent"] = "admin";
|
|
153
|
+
// Basic admin auth IS admin — resource-level checks (SemanticSearch,
|
|
154
|
+
// MemoryBootstrap, MemoryReflect, MemoryConsolidate) gate cross-agent
|
|
155
|
+
// access on this flag. Prior to 0.5.5 the check was a no-op so this
|
|
156
|
+
// was never needed; now it must be set.
|
|
157
|
+
request.tpsAgent = "admin";
|
|
158
|
+
request.tpsAgentIsAdmin = true;
|
|
153
159
|
return nextLayer(request);
|
|
154
160
|
}
|
|
155
161
|
}
|