@getbourdon/participants 0.3.0 → 0.4.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/dist/base.js CHANGED
@@ -1,3 +1,4 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
1
2
  /**
2
3
  * Bourdon participant base — the Protocol → TS interface, errors, and the
3
4
  * AgentStore / HealthStatus types. Faithful port of `participants/base.py`.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared mechanics for the "JSON export store" participant family.
3
+ *
4
+ * Bolt.new, Lovable and v0 are cloud builders that persist nothing useful on
5
+ * the user's machine, so each federates through a directory the user exports
6
+ * into:
7
+ *
8
+ * $<AGENT>_STATE_DIR/ (default: ~/.<agent>/)
9
+ * projects.json array of project records, or {projects: [...]}
10
+ * sessions/<id>.json one JSON document per session
11
+ *
12
+ * The oracle implements this three times (`participants/{bolt,lovable,v0}.py`,
13
+ * ~1,130 lines). The store layout, the date parser, the visibility parser, the
14
+ * loader's error semantics and the health-check ladder are identical across
15
+ * all three, so they live here once.
16
+ *
17
+ * ⚠ What is NOT shared: each reader's `projectToEntity` / `sessionToRecord`.
18
+ * They look similar and are not the same — Lovable caps a project name at 120
19
+ * characters where Bolt caps at 80, emits a NULL summary where Bolt emits a
20
+ * `"Bolt.new project <name>"` fallback, and appends its own tag
21
+ * unconditionally where Bolt inserts only if absent (so Lovable really can
22
+ * emit a duplicate tag). Those differences are the oracle's behaviour and the
23
+ * mirror reproduces them rather than tidying them into agreement; each reader
24
+ * supplies its own mapper.
25
+ */
26
+ import { Visibility, type EntityModel, type L5ManifestModel, type SessionModel, type VisibilityPolicyModel } from "@getbourdon/l5";
27
+ import { type AgentStore, type HealthStatus } from "./base.js";
28
+ type Dict = Record<string, unknown>;
29
+ /**
30
+ * `_parse_visibility`: a recognized visibility string, else null (meaning
31
+ * "inherit the policy default"), never a throw.
32
+ */
33
+ export declare function parseVisibility(raw: unknown): Visibility | null;
34
+ /**
35
+ * `_iso_date`: normalize to a bare `YYYY-MM-DD`, or null if unparseable.
36
+ *
37
+ * Accepts a full ISO timestamp (trailing `Z` included) and reduces it to its
38
+ * date, which is what every L5 date field carries.
39
+ */
40
+ export declare function isoDate(raw: unknown): string | null;
41
+ /** `_safe_str`: trim, then route through the redaction SSOT. "" stays "". */
42
+ export declare function safeStr(raw: unknown, limit?: number): string;
43
+ /** `_str_list` in the Bolt/v0 spelling: redact every item, drop the empties. */
44
+ export declare function safeStrList(raw: unknown, limit?: number): string[];
45
+ export interface ExportStoreConfig {
46
+ agentId: string;
47
+ agentType: string;
48
+ displayName: string;
49
+ /** Directory name under $HOME, e.g. `.bolt`. */
50
+ directoryName: string;
51
+ /** Environment variable that overrides the state directory. */
52
+ stateEnvVar: string;
53
+ roleNarrative: string;
54
+ capabilities: string[];
55
+ policy: VisibilityPolicyModel;
56
+ /** Human label used in error and health text, e.g. "Bolt.new". */
57
+ label: string;
58
+ /** The `bourdon <x> init` verb named in remediation text. */
59
+ initCommand: string;
60
+ projectToEntity: (record: Dict) => EntityModel | null;
61
+ sessionToRecord: (record: Dict, fallbackDate: string | null) => SessionModel | null;
62
+ }
63
+ export declare class ExportStoreParticipant {
64
+ readonly agentId: string;
65
+ readonly agentType: string;
66
+ readonly displayName: string;
67
+ private readonly config;
68
+ private readonly stateDir;
69
+ private readonly env;
70
+ constructor(config: ExportStoreConfig, options?: {
71
+ stateDir?: string;
72
+ env?: NodeJS.ProcessEnv;
73
+ });
74
+ static defaultNativePath(directoryName: string, home?: string): string;
75
+ get nativePath(): string;
76
+ private resolveStateDir;
77
+ private requireStateDir;
78
+ /**
79
+ * `projects.json`, accepting either a bare array or `{projects: [...]}`.
80
+ *
81
+ * A MISSING file is empty (the store may legitimately hold only sessions);
82
+ * a PRESENT but unreadable or wrongly-shaped file throws. The distinction is
83
+ * the point: absence is a state, corruption is an error.
84
+ */
85
+ private loadProjects;
86
+ private sessionFiles;
87
+ /** Newest first. An unreadable session is skipped, never fatal. */
88
+ private loadSessions;
89
+ discover(): AgentStore;
90
+ exportSessions(since: Date, limit?: number): SessionModel[];
91
+ exportL5(since?: Date | null): L5ManifestModel;
92
+ /** MUST NOT throw: the L6 server calls this in a polling loop. */
93
+ healthCheck(): HealthStatus;
94
+ private resolveStateDirSafe;
95
+ }
96
+ export {};
@@ -0,0 +1,316 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ /**
3
+ * Shared mechanics for the "JSON export store" participant family.
4
+ *
5
+ * Bolt.new, Lovable and v0 are cloud builders that persist nothing useful on
6
+ * the user's machine, so each federates through a directory the user exports
7
+ * into:
8
+ *
9
+ * $<AGENT>_STATE_DIR/ (default: ~/.<agent>/)
10
+ * projects.json array of project records, or {projects: [...]}
11
+ * sessions/<id>.json one JSON document per session
12
+ *
13
+ * The oracle implements this three times (`participants/{bolt,lovable,v0}.py`,
14
+ * ~1,130 lines). The store layout, the date parser, the visibility parser, the
15
+ * loader's error semantics and the health-check ladder are identical across
16
+ * all three, so they live here once.
17
+ *
18
+ * ⚠ What is NOT shared: each reader's `projectToEntity` / `sessionToRecord`.
19
+ * They look similar and are not the same — Lovable caps a project name at 120
20
+ * characters where Bolt caps at 80, emits a NULL summary where Bolt emits a
21
+ * `"Bolt.new project <name>"` fallback, and appends its own tag
22
+ * unconditionally where Bolt inserts only if absent (so Lovable really can
23
+ * emit a duplicate tag). Those differences are the oracle's behaviour and the
24
+ * mirror reproduces them rather than tidying them into agreement; each reader
25
+ * supplies its own mapper.
26
+ */
27
+ import { readdirSync, readFileSync, statSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { Visibility, filterForFederation, makeAgentInfo, makeManifest, } from "@getbourdon/l5";
31
+ import { redactText } from "@getbourdon/redaction";
32
+ import { ParticipantDiscoveryError, ParticipantExportError, SPEC_VERSION } from "./base.js";
33
+ const VISIBILITY_VALUES = new Set(Object.values(Visibility));
34
+ /**
35
+ * `_parse_visibility`: a recognized visibility string, else null (meaning
36
+ * "inherit the policy default"), never a throw.
37
+ */
38
+ export function parseVisibility(raw) {
39
+ if (typeof raw !== "string")
40
+ return null;
41
+ const value = raw.trim().toLowerCase();
42
+ return VISIBILITY_VALUES.has(value) ? value : null;
43
+ }
44
+ /**
45
+ * `_iso_date`: normalize to a bare `YYYY-MM-DD`, or null if unparseable.
46
+ *
47
+ * Accepts a full ISO timestamp (trailing `Z` included) and reduces it to its
48
+ * date, which is what every L5 date field carries.
49
+ */
50
+ export function isoDate(raw) {
51
+ if (typeof raw !== "string" || !raw.trim())
52
+ return null;
53
+ const text = raw.trim();
54
+ if (text.includes("T")) {
55
+ const parsed = new Date(text.replace(/Z$/, "+00:00"));
56
+ if (Number.isNaN(parsed.getTime()))
57
+ return null;
58
+ return parsed.toISOString().slice(0, 10);
59
+ }
60
+ // Python's strptime("%Y-%m-%d") rejects anything else, including a real
61
+ // date in another format — matched here rather than letting Date() guess.
62
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text))
63
+ return null;
64
+ const parsed = new Date(`${text}T00:00:00Z`);
65
+ if (Number.isNaN(parsed.getTime()))
66
+ return null;
67
+ // Rejects 2026-02-31, which the regex alone would accept.
68
+ return parsed.toISOString().slice(0, 10) === text ? text : null;
69
+ }
70
+ /** `_safe_str`: trim, then route through the redaction SSOT. "" stays "". */
71
+ export function safeStr(raw, limit = 240) {
72
+ if (raw === null || raw === undefined)
73
+ return "";
74
+ const text = String(raw).trim();
75
+ return text ? redactText(text, limit) : "";
76
+ }
77
+ /** `_str_list` in the Bolt/v0 spelling: redact every item, drop the empties. */
78
+ export function safeStrList(raw, limit = 200) {
79
+ if (!Array.isArray(raw))
80
+ return [];
81
+ const out = [];
82
+ for (const item of raw) {
83
+ const cleaned = safeStr(item, limit);
84
+ if (cleaned)
85
+ out.push(cleaned);
86
+ }
87
+ return out;
88
+ }
89
+ export class ExportStoreParticipant {
90
+ agentId;
91
+ agentType;
92
+ displayName;
93
+ config;
94
+ stateDir;
95
+ env;
96
+ constructor(config, options = {}) {
97
+ this.config = config;
98
+ this.agentId = config.agentId;
99
+ this.agentType = config.agentType;
100
+ this.displayName = config.displayName;
101
+ this.stateDir = options.stateDir;
102
+ this.env = options.env ?? process.env;
103
+ }
104
+ static defaultNativePath(directoryName, home) {
105
+ return join(home ?? homedir(), directoryName);
106
+ }
107
+ get nativePath() {
108
+ return this.resolveStateDir();
109
+ }
110
+ resolveStateDir() {
111
+ if (this.stateDir !== undefined)
112
+ return this.stateDir;
113
+ const override = (this.env[this.config.stateEnvVar] ?? "").trim();
114
+ if (override)
115
+ return override;
116
+ return ExportStoreParticipant.defaultNativePath(this.config.directoryName);
117
+ }
118
+ requireStateDir() {
119
+ const dir = this.resolveStateDir();
120
+ if (!isDir(dir)) {
121
+ throw new ParticipantDiscoveryError(`${this.config.label} state directory not found at '${dir}'.`);
122
+ }
123
+ return dir;
124
+ }
125
+ /**
126
+ * `projects.json`, accepting either a bare array or `{projects: [...]}`.
127
+ *
128
+ * A MISSING file is empty (the store may legitimately hold only sessions);
129
+ * a PRESENT but unreadable or wrongly-shaped file throws. The distinction is
130
+ * the point: absence is a state, corruption is an error.
131
+ */
132
+ loadProjects(stateDir) {
133
+ const path = join(stateDir, "projects.json");
134
+ if (!isFile(path))
135
+ return [];
136
+ let payload;
137
+ try {
138
+ payload = JSON.parse(stripBom(readFileSync(path, "utf-8")));
139
+ }
140
+ catch (exc) {
141
+ throw new ParticipantExportError(`${this.config.label} projects store present but unreadable at ${path}: ${exc instanceof Error ? exc.message : String(exc)}`);
142
+ }
143
+ if (Array.isArray(payload))
144
+ return payload.filter(isDict);
145
+ if (isDict(payload) && Array.isArray(payload.projects))
146
+ return payload.projects.filter(isDict);
147
+ throw new ParticipantExportError(`${this.config.label} projects store at ${path} must be a JSON array or object with projects list.`);
148
+ }
149
+ sessionFiles(stateDir) {
150
+ const dir = join(stateDir, "sessions");
151
+ if (!isDir(dir))
152
+ return [];
153
+ return readdirSync(dir)
154
+ .filter((f) => f.endsWith(".json"))
155
+ .sort()
156
+ .map((f) => join(dir, f));
157
+ }
158
+ /** Newest first. An unreadable session is skipped, never fatal. */
159
+ loadSessions(stateDir) {
160
+ const out = [];
161
+ for (const path of this.sessionFiles(stateDir)) {
162
+ let payload;
163
+ try {
164
+ payload = JSON.parse(stripBom(readFileSync(path, "utf-8")));
165
+ }
166
+ catch {
167
+ continue;
168
+ }
169
+ if (!isDict(payload))
170
+ continue;
171
+ const session = this.config.sessionToRecord(payload, null);
172
+ if (session !== null)
173
+ out.push(session);
174
+ }
175
+ out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
176
+ return out;
177
+ }
178
+ discover() {
179
+ const stateDir = this.resolveStateDir();
180
+ if (!isDir(stateDir)) {
181
+ throw new ParticipantDiscoveryError(`${this.config.label} state directory not found at '${stateDir}'. ` +
182
+ `Create it and add a projects.json export or run \`${this.config.initCommand}\`.`);
183
+ }
184
+ return {
185
+ path: stateDir,
186
+ version: "export-v1",
187
+ metadata: {
188
+ projects_file: isFile(join(stateDir, "projects.json")),
189
+ session_files: this.sessionFiles(stateDir).length,
190
+ env_override: Boolean((this.env[this.config.stateEnvVar] ?? "").trim()),
191
+ },
192
+ };
193
+ }
194
+ exportSessions(since, limit = 100) {
195
+ const stateDir = this.requireStateDir();
196
+ const sinceIso = since.toISOString().slice(0, 10);
197
+ // `limit` is honoured, not decorative: a caller asking for 5 gets 5.
198
+ return this.loadSessions(stateDir)
199
+ .filter((s) => s.date >= sinceIso)
200
+ .slice(0, limit);
201
+ }
202
+ exportL5(since) {
203
+ const stateDir = this.requireStateDir();
204
+ let sessions = this.loadSessions(stateDir);
205
+ if (since !== undefined && since !== null) {
206
+ const sinceIso = since.toISOString().slice(0, 10);
207
+ sessions = sessions.filter((s) => s.date >= sinceIso);
208
+ }
209
+ const entities = [];
210
+ const seen = new Set();
211
+ for (const record of this.loadProjects(stateDir)) {
212
+ const entity = this.config.projectToEntity(record);
213
+ if (entity === null)
214
+ continue;
215
+ const key = entity.name.toLowerCase();
216
+ if (seen.has(key))
217
+ continue;
218
+ seen.add(key);
219
+ entities.push(entity);
220
+ }
221
+ // The PII guardrail, and it belongs HERE: L6 trusts whatever a participant
222
+ // emits, so an entity that resolves to PRIVATE must never leave this
223
+ // function. Filtering downstream would be too late.
224
+ const visibleEntities = filterForFederation(entities, this.config.policy);
225
+ return makeManifest({
226
+ spec_version: SPEC_VERSION,
227
+ agent: makeAgentInfo({
228
+ id: this.config.agentId,
229
+ type: this.config.agentType,
230
+ role_narrative: this.config.roleNarrative,
231
+ spec_version_compat: SPEC_VERSION,
232
+ }),
233
+ last_updated: new Date().toISOString().replace(/\.\d{3}Z$/, "+00:00"),
234
+ capabilities: [...this.config.capabilities],
235
+ recent_sessions: sessions,
236
+ known_entities: visibleEntities,
237
+ visibility_policy: this.config.policy,
238
+ });
239
+ }
240
+ /** MUST NOT throw: the L6 server calls this in a polling loop. */
241
+ healthCheck() {
242
+ try {
243
+ const stateDir = this.resolveStateDir();
244
+ if (!isDir(stateDir)) {
245
+ return {
246
+ status: "blocked",
247
+ reason: `${this.config.label} state directory not found at '${stateDir}'.`,
248
+ details: { expected: stateDir },
249
+ proposedFix: `Run \`${this.config.initCommand}\` or create ${stateDir} and add projects.json.`,
250
+ };
251
+ }
252
+ const projectsFile = join(stateDir, "projects.json");
253
+ const sessionFiles = this.sessionFiles(stateDir);
254
+ if (!isFile(projectsFile) && sessionFiles.length === 0) {
255
+ return {
256
+ status: "degraded",
257
+ reason: "State directory exists but contains no projects.json or sessions.",
258
+ details: { path: stateDir },
259
+ proposedFix: `Run \`${this.config.initCommand}\` or export projects into projects.json.`,
260
+ };
261
+ }
262
+ // Parse both, so a corrupt store reports degraded here instead of
263
+ // throwing later inside a federation read.
264
+ this.loadProjects(stateDir);
265
+ this.loadSessions(stateDir);
266
+ return {
267
+ status: "ok",
268
+ details: {
269
+ path: stateDir,
270
+ projects_file: isFile(projectsFile),
271
+ session_files: sessionFiles.length,
272
+ },
273
+ };
274
+ }
275
+ catch (exc) {
276
+ return {
277
+ status: "degraded",
278
+ reason: exc instanceof Error ? exc.message : String(exc),
279
+ details: {},
280
+ proposedFix: `Inspect ${this.resolveStateDirSafe()}/projects.json for syntax errors.`,
281
+ };
282
+ }
283
+ }
284
+ resolveStateDirSafe() {
285
+ try {
286
+ return this.resolveStateDir();
287
+ }
288
+ catch {
289
+ return `~/${this.config.directoryName}`;
290
+ }
291
+ }
292
+ }
293
+ // -- helpers ------------------------------------------------------------------
294
+ function isDict(v) {
295
+ return v !== null && typeof v === "object" && !Array.isArray(v);
296
+ }
297
+ function isDir(p) {
298
+ try {
299
+ return statSync(p).isDirectory();
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
305
+ function isFile(p) {
306
+ try {
307
+ return statSync(p).isFile();
308
+ }
309
+ catch {
310
+ return false;
311
+ }
312
+ }
313
+ /** Python reads these with `encoding="utf-8-sig"`, so a BOM is not a parse error. */
314
+ function stripBom(text) {
315
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
316
+ }