@getbourdon/participants 0.1.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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Network-shaped participant base — port of `participants/_network_base.py`
3
+ * (v0.3 adapter contract). Same `exportL5` / `exportSessions` half as a file
4
+ * participant; only the discovery half differs (fetch from an authenticated
5
+ * API). The base provides the three guarantees so adapters don't reimplement:
6
+ *
7
+ * 1. Local TTL cache under `~/.cache/bourdon/<slug>/payload.json` storing ONLY
8
+ * `{fetched_at, payload}` — NEVER the token. A cache hit = zero network.
9
+ * 2. Graceful degradation: network down → serve the most recent cached payload
10
+ * (even expired) and report `degraded` (not silence).
11
+ * 3. Auth boundary: an injected lazy {@link AuthProvider} (env / keychain).
12
+ * An invalid token (401/403 non-ratelimit) ALWAYS propagates as `blocked`
13
+ * and is NEVER masked by stale cache; a missing token serves stale when a
14
+ * cache exists, else blocks; rate-limit degrades.
15
+ *
16
+ * A subclass sets `participantSlug` / `agentId` / `agentType` (+ `nativePath`)
17
+ * and implements `fetchPayload(token)` + `payloadToL5(payload)` (pure, no I/O).
18
+ */
19
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { ParticipantError, } from "./base.js";
23
+ export const NETWORK_CONTRACT_VERSION = "0.3";
24
+ /** Default cache TTL — adapters override via `cacheTtlSeconds`. */
25
+ export const DEFAULT_CACHE_TTL_SECONDS = 15 * 60; // 15 minutes
26
+ // -- Network-specific errors --------------------------------------------------
27
+ /** Transport failure / timeout / 5xx / rate-limit — recoverable via cache fallback. */
28
+ export class NetworkUnavailable extends ParticipantError {
29
+ }
30
+ /** 401/403 / missing credential — NOT recoverable via cache. The user must fix
31
+ * auth; surfaced as `blocked`, never silently served from stale cache (stale
32
+ * data would mask a broken token indefinitely). */
33
+ export class ParticipantAuthError extends ParticipantError {
34
+ }
35
+ /** Auth provider that reads a token from an environment variable. */
36
+ export function envAuthProvider(varName) {
37
+ return () => process.env[varName] || null;
38
+ }
39
+ // -- Cache --------------------------------------------------------------------
40
+ /** `$XDG_CACHE_HOME/bourdon` or `~/.cache/bourdon`. */
41
+ export function defaultCacheRoot() {
42
+ const xdg = process.env.XDG_CACHE_HOME;
43
+ const base = xdg ? xdg : join(homedir(), ".cache");
44
+ return join(base, "bourdon");
45
+ }
46
+ /** A cached payload + the wall-clock time it was fetched (epoch seconds). */
47
+ export class CacheEntry {
48
+ fetchedAt;
49
+ payload;
50
+ constructor(fetchedAt, payload) {
51
+ this.fetchedAt = fetchedAt;
52
+ this.payload = payload;
53
+ }
54
+ get ageSeconds() {
55
+ return Math.max(0, Date.now() / 1000 - this.fetchedAt);
56
+ }
57
+ isFresh(ttlSeconds) {
58
+ return this.ageSeconds < ttlSeconds;
59
+ }
60
+ }
61
+ /**
62
+ * A tiny JSON file cache, one file per participant slug. Stores only
63
+ * `{fetched_at, payload}` — never the auth token. Read/write are best-effort: a
64
+ * corrupt or unwritable cache degrades to "no cache", never raises.
65
+ */
66
+ export class PayloadCache {
67
+ slug;
68
+ root;
69
+ path;
70
+ constructor(slug, rootDir) {
71
+ this.slug = slug;
72
+ this.root = join(rootDir ?? defaultCacheRoot(), slug);
73
+ this.path = join(this.root, "payload.json");
74
+ }
75
+ read() {
76
+ let raw;
77
+ try {
78
+ raw = JSON.parse(readFileSync(this.path, "utf8"));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ if (typeof raw !== "object" || raw === null || !("payload" in raw))
84
+ return null;
85
+ const obj = raw;
86
+ let fetchedAt = Number(obj["fetched_at"] ?? 0);
87
+ if (!Number.isFinite(fetchedAt))
88
+ fetchedAt = 0;
89
+ const payload = obj["payload"];
90
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload))
91
+ return null;
92
+ return new CacheEntry(fetchedAt, payload);
93
+ }
94
+ write(payload) {
95
+ try {
96
+ mkdirSync(this.root, { recursive: true });
97
+ const tmp = `${this.path}.tmp`;
98
+ writeFileSync(tmp, JSON.stringify({ fetched_at: Date.now() / 1000, payload }), "utf8");
99
+ renameSync(tmp, this.path); // atomic
100
+ }
101
+ catch {
102
+ /* best-effort; a write failure degrades to "no cache" */
103
+ }
104
+ }
105
+ }
106
+ // -- Base participant ---------------------------------------------------------
107
+ /**
108
+ * Base for participants whose native state lives behind an authenticated API.
109
+ * Caching, degradation, the auth boundary, and the
110
+ * `discover`/`exportL5`/`exportSessions`/`healthCheck` plumbing live here;
111
+ * subclasses provide only `fetchPayload` + `payloadToL5`.
112
+ */
113
+ export class NetworkParticipant {
114
+ participantSlug = "network";
115
+ agentId = "network";
116
+ agentType = "other";
117
+ cacheTtlSeconds = DEFAULT_CACHE_TTL_SECONDS;
118
+ nativePath = "network://";
119
+ authProvider;
120
+ cacheRoot;
121
+ cacheInstance;
122
+ constructor(opts = {}) {
123
+ this.authProvider = opts.authProvider ?? null;
124
+ this.cacheRoot = opts.cacheRoot;
125
+ }
126
+ /** Lazily built so it reads the SUBCLASS's `participantSlug` — subclass field
127
+ * initializers run after `super()`, so building the cache in the constructor
128
+ * would capture the base "network" slug instead. */
129
+ get cache() {
130
+ if (!this.cacheInstance) {
131
+ this.cacheInstance = new PayloadCache(this.participantSlug, this.cacheRoot);
132
+ }
133
+ return this.cacheInstance;
134
+ }
135
+ // -- Core fetch-with-cache-and-degrade -------------------------------------
136
+ /**
137
+ * Resolve (payload, source) where source is 'cache' | 'network' | 'stale-cache'.
138
+ * 1. Fresh cache hit → 'cache' (no network)
139
+ * 2. Miss/expired + token → 'network' (fetch + refresh cache)
140
+ * 3. Net fails + any cache → 'stale-cache' (serve last good payload)
141
+ * 4. Net fails + no cache → raise
142
+ * Auth errors are never swallowed — they propagate so health surfaces
143
+ * 'blocked' rather than serving stale data behind a dead token.
144
+ */
145
+ getPayload() {
146
+ const cached = this.cache.read();
147
+ if (cached !== null && cached.isFresh(this.cacheTtlSeconds)) {
148
+ return { payload: cached.payload, source: "cache" };
149
+ }
150
+ const token = this.authProvider ? this.authProvider() : null;
151
+ if (!token) {
152
+ // No credential: if we have any cache, serve it stale; else blocked.
153
+ if (cached !== null)
154
+ return { payload: cached.payload, source: "stale-cache" };
155
+ throw new ParticipantAuthError(`${this.participantSlug}: no auth token available and no cache`);
156
+ }
157
+ let payload;
158
+ try {
159
+ payload = this.fetchPayload(token);
160
+ }
161
+ catch (err) {
162
+ if (err instanceof ParticipantAuthError)
163
+ throw err; // never mask a bad token
164
+ if (err instanceof NetworkUnavailable) {
165
+ if (cached !== null)
166
+ return { payload: cached.payload, source: "stale-cache" };
167
+ throw err;
168
+ }
169
+ throw err;
170
+ }
171
+ this.cache.write(payload);
172
+ return { payload, source: "network" };
173
+ }
174
+ // -- Participant protocol ---------------------------------------------------
175
+ discover() {
176
+ const { source } = this.getPayload();
177
+ return {
178
+ path: this.nativePath,
179
+ version: `${this.participantSlug}-network-v1`,
180
+ metadata: { source, contract_version: NETWORK_CONTRACT_VERSION },
181
+ };
182
+ }
183
+ exportL5(_since) {
184
+ const { payload } = this.getPayload();
185
+ return this.payloadToL5(payload);
186
+ }
187
+ exportSessions(since, limit = 100) {
188
+ const manifest = this.exportL5(since);
189
+ let sessions = manifest.recent_sessions ?? [];
190
+ if (since) {
191
+ const cutoff = since.toISOString().slice(0, 10);
192
+ sessions = sessions.filter((s) => (s.date || "") >= cutoff);
193
+ }
194
+ return sessions.slice(0, limit);
195
+ }
196
+ healthCheck() {
197
+ let source;
198
+ try {
199
+ source = this.getPayload().source;
200
+ }
201
+ catch (err) {
202
+ if (err instanceof ParticipantAuthError) {
203
+ return {
204
+ status: "blocked",
205
+ reason: String(err.message),
206
+ details: { participant: this.participantSlug },
207
+ proposedFix: `Provide a valid credential for ${this.participantSlug} ` +
208
+ "(see the adapter docs for the auth path) and re-run `bourdon export-all`.",
209
+ };
210
+ }
211
+ if (err instanceof NetworkUnavailable) {
212
+ return {
213
+ status: "degraded",
214
+ reason: `network unavailable and no cache: ${err.message}`,
215
+ details: { participant: this.participantSlug },
216
+ proposedFix: "Check connectivity, then re-run `bourdon export-all`.",
217
+ };
218
+ }
219
+ // Defense in depth: health_check must NEVER raise. An adapter's
220
+ // fetchPayload should only raise the two contract errors; anything else is
221
+ // reported as blocked rather than propagated.
222
+ return {
223
+ status: "blocked",
224
+ reason: `unexpected error resolving payload: ${String(err)}`,
225
+ details: { participant: this.participantSlug },
226
+ proposedFix: `${this.participantSlug} raised an unexpected error during healthCheck; ` +
227
+ "its fetchPayload should only raise ParticipantAuthError / NetworkUnavailable.",
228
+ };
229
+ }
230
+ const cached = this.cache.read();
231
+ const details = { participant: this.participantSlug, source };
232
+ if (cached !== null) {
233
+ details["cache_age_seconds"] = Math.round(cached.ageSeconds * 10) / 10;
234
+ details["cache_fetched_at"] = new Date(cached.fetchedAt * 1000)
235
+ .toISOString()
236
+ .replace(/\.\d{3}Z$/, "+00:00");
237
+ }
238
+ if (source === "stale-cache") {
239
+ return {
240
+ status: "degraded",
241
+ reason: "serving stale cache (network unavailable or no token)",
242
+ details,
243
+ proposedFix: "Restore connectivity / refresh the token to update.",
244
+ };
245
+ }
246
+ return { status: "ok", details };
247
+ }
248
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Claude Code participant — reads claude-brain + auto-memory + MCP knowledge
3
+ * graph. Port of `participants/claude_code.py`.
4
+ *
5
+ * Three optional sources (the participant degrades gracefully):
6
+ * 1. claude-brain/ — git-synced markdown records
7
+ * 2. ~/.claude/projects/<ws>/memory/ — per-machine auto-memory frontmatter
8
+ * 3. ~/claude-memory/memory.jsonl — MCP knowledge-graph JSONL
9
+ *
10
+ * Privacy: entities typed `person` (+ family/contact variants) and observations
11
+ * containing credential-like strings default to PRIVATE and are filtered before
12
+ * federation. Entities are deduped by case-insensitive name and sorted
13
+ * alphabetically.
14
+ */
15
+ import { type L5ManifestModel, type SessionModel } from "@getbourdon/l5";
16
+ import { type AgentStore, type BourdonParticipant, type HealthStatus } from "../base.js";
17
+ /** External participant for Anthropic's Claude Code CLI. */
18
+ export declare class ClaudeCodeParticipant implements BourdonParticipant {
19
+ agentId: string;
20
+ agentType: string;
21
+ nativePath: string;
22
+ /** Public so a hermetic test can seed the discovered sources directly (path
23
+ * RESOLUTION is not the parity contract — only the export output shape is). */
24
+ brainPath: string | null;
25
+ autoMemoryPath: string | null;
26
+ knowledgeGraphPath: string | null;
27
+ static defaultNativePath(home?: string): string;
28
+ constructor();
29
+ discover(): AgentStore;
30
+ exportL5(since?: Date): L5ManifestModel;
31
+ exportSessions(since?: Date, limit?: number): SessionModel[];
32
+ healthCheck(): HealthStatus;
33
+ }