@tpsdev-ai/flair 0.46.0 → 0.47.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/build-info.json +6 -0
- package/dist/cli.js +479 -163
- package/dist/doctor-client.js +92 -11
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +12 -2
- package/dist/lib/scheduler-platform.js +153 -0
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +15 -9
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +153 -0
- package/dist/src/rem/scheduler.js +15 -9
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +42 -0
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
|
@@ -39,8 +39,114 @@ export const DEFAULT_MAX_TOKENS = 2000;
|
|
|
39
39
|
* over creative extrapolation.
|
|
40
40
|
*/
|
|
41
41
|
export const GENERATE_TEMPERATURE = 0.2;
|
|
42
|
+
// ─── Continuity-journal distillation (flair#1257 slice 3) ────────────────────
|
|
43
|
+
// The session-continuity journal (slice 2, #1283) writes ephemeral+private
|
|
44
|
+
// rows tagged `adk:continuity:<sessionId>`. REM distills those journals with
|
|
45
|
+
// the SAME scope:"tagged" machinery ADK per-user tags use (#1205b) — slice 3
|
|
46
|
+
// is wiring and guards, not a new engine. The constants/predicates here are
|
|
47
|
+
// the continuity-specific guards:
|
|
48
|
+
//
|
|
49
|
+
// - stale-intent (two layers, Kern-ruled): the distiller prompt carries the
|
|
50
|
+
// rule (primary), AND a text-shape post-filter drops in-flight-intent-
|
|
51
|
+
// shaped candidates when the source session is stale (testable
|
|
52
|
+
// defense-in-depth — see filterStaleSessionIntentCandidates).
|
|
53
|
+
// - visibility (Sherlock-ruled, default-private-unless): promotion out of
|
|
54
|
+
// ephemeral+private is a visibility ESCALATION from the most sensitive
|
|
55
|
+
// tier. The distiller may rule "shared" only AFFIRMATIVELY, with a
|
|
56
|
+
// team-relevance justification recorded on the candidate — never by
|
|
57
|
+
// default, never silently (see resolveCandidateVisibilityRuling).
|
|
58
|
+
/** Tag prefix for continuity-journal rows (`adk:continuity:<sessionId>`).
|
|
59
|
+
* Canonical string duplicated in packages/flair-mcp/src/continuity.ts
|
|
60
|
+
* (CONTINUITY_TAG_PREFIX — the writer) and src/rem/runner.ts — the three
|
|
61
|
+
* live on opposite sides of npm-packaging boundaries (resources/ ships as
|
|
62
|
+
* the Harper component; src/ and packages/ ship separately; imports across
|
|
63
|
+
* them don't survive packaging — see src/cli.ts's header). Kept in sync by
|
|
64
|
+
* the shared canonical string. */
|
|
65
|
+
export const CONTINUITY_SCOPE_TAG_PREFIX = "adk:continuity:";
|
|
66
|
+
/** True iff `tag` is a continuity-journal scope tag (has a non-empty
|
|
67
|
+
* sessionId component — the bare prefix is not a session). */
|
|
68
|
+
export function isContinuityScopeTag(tag) {
|
|
69
|
+
return typeof tag === "string" && tag.length > CONTINUITY_SCOPE_TAG_PREFIX.length && tag.startsWith(CONTINUITY_SCOPE_TAG_PREFIX);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Staleness horizon for the stale-intent guard (spec item 3, default 72h,
|
|
73
|
+
* FLAIR_REM_STALE_INTENT_HOURS). A journal entry like "about to merge X" is
|
|
74
|
+
* useful context shortly after the session died (the intent may still be
|
|
75
|
+
* live); past this horizon the intent has resolved or died, and promoting it
|
|
76
|
+
* manufactures a false present. Distinct from the SETTLE window (2h,
|
|
77
|
+
* src/rem/runner.ts) — settle decides when a session may be distilled at
|
|
78
|
+
* all; this horizon decides whether in-flight-intent content from it may
|
|
79
|
+
* still promote.
|
|
80
|
+
*/
|
|
81
|
+
export const DEFAULT_STALE_INTENT_HORIZON_MS = 72 * 3600_000;
|
|
82
|
+
/**
|
|
83
|
+
* Text shapes that mark a candidate as IN-FLIGHT INTENT — an action described
|
|
84
|
+
* as pending/current rather than decided/done. Deliberately the obvious
|
|
85
|
+
* shapes only (Kern's ruling: the prompt rule is the primary layer; this
|
|
86
|
+
* post-filter is testable defense-in-depth and need not be exhaustive).
|
|
87
|
+
* Case-insensitive; word-bounded so e.g. "roundabout to" doesn't match.
|
|
88
|
+
*/
|
|
89
|
+
export const IN_FLIGHT_INTENT_PATTERNS = [
|
|
90
|
+
/\babout to\b/i,
|
|
91
|
+
/\bwaiting (?:on|for)\b/i,
|
|
92
|
+
/\bgoing to\b/i,
|
|
93
|
+
/\bplanning to\b/i,
|
|
94
|
+
];
|
|
95
|
+
/** True iff `text` matches an in-flight-intent shape. */
|
|
96
|
+
export function isInFlightIntentShaped(text) {
|
|
97
|
+
return IN_FLIGHT_INTENT_PATTERNS.some((p) => p.test(text));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The stale-intent POST-FILTER (spec item 3, the testable layer). When the
|
|
101
|
+
* source session is STALE — its newest entry older than `horizonMs` — drop
|
|
102
|
+
* every candidate whose claim is in-flight-intent-shaped. Runs AFTER
|
|
103
|
+
* parseAndValidateCandidates (a drop here is a policy skip, never a batch
|
|
104
|
+
* failure) and BEFORE dedup/staging.
|
|
105
|
+
*
|
|
106
|
+
* Fresh sessions pass everything through (an "about to merge X" from two
|
|
107
|
+
* hours ago is genuinely useful resume context). Stale sessions still
|
|
108
|
+
* promote DECISION-class content — the filter drops only the in-flight
|
|
109
|
+
* shapes, which is the positive control the acceptance set demands.
|
|
110
|
+
*
|
|
111
|
+
* An UNDATEABLE session (no newest-entry timestamp) is treated as STALE:
|
|
112
|
+
* this guard exists to stop manufactured false-presents, and "can't tell how
|
|
113
|
+
* old" must fail toward filtering, not toward promoting (fail-closed).
|
|
114
|
+
*/
|
|
115
|
+
export function filterStaleSessionIntentCandidates(candidates, params) {
|
|
116
|
+
const horizonMs = params.horizonMs ?? DEFAULT_STALE_INTENT_HORIZON_MS;
|
|
117
|
+
const newestMs = params.sessionNewestCreatedAt ? new Date(params.sessionNewestCreatedAt).getTime() : NaN;
|
|
118
|
+
const sessionStale = !Number.isFinite(newestMs) || params.now.getTime() - newestMs > horizonMs;
|
|
119
|
+
if (!sessionStale)
|
|
120
|
+
return { kept: candidates, droppedStaleIntent: [] };
|
|
121
|
+
const kept = [];
|
|
122
|
+
const droppedStaleIntent = [];
|
|
123
|
+
for (const c of candidates) {
|
|
124
|
+
(isInFlightIntentShaped(c.claim) ? droppedStaleIntent : kept).push(c);
|
|
125
|
+
}
|
|
126
|
+
return { kept, droppedStaleIntent };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Resolve a distilled candidate's visibility ruling (Sherlock's
|
|
130
|
+
* default-private-unless, flair#1257 slice 3). Returns a ruling ONLY when the
|
|
131
|
+
* distiller AFFIRMATIVELY ruled "shared" AND recorded a non-empty
|
|
132
|
+
* team-relevance justification — anything less (absent, "private", "shared"
|
|
133
|
+
* with no justification, whitespace justification) returns null, which
|
|
134
|
+
* downstream reads as the private default. The uncertainty fallback is
|
|
135
|
+
* private, fail-closed; a shared promoted row must always trace to a
|
|
136
|
+
* recorded justification on its candidate, never to a default.
|
|
137
|
+
*/
|
|
138
|
+
export function resolveCandidateVisibilityRuling(candidate) {
|
|
139
|
+
if (candidate.visibility !== "shared")
|
|
140
|
+
return null;
|
|
141
|
+
const rationale = typeof candidate.teamRelevance === "string" ? candidate.teamRelevance.trim() : "";
|
|
142
|
+
if (rationale.length === 0)
|
|
143
|
+
return null;
|
|
144
|
+
return { ruling: "shared", rationale };
|
|
145
|
+
}
|
|
42
146
|
// ─── Candidate shape (spec §3A) ───────────────────────────────────────────────
|
|
43
147
|
// { candidates: [ { claim: string, sourceMemoryIds: string[], tags?: string[] } ] }
|
|
148
|
+
// Continuity runs (flair#1257 slice 3) may additionally carry per-candidate
|
|
149
|
+
// `visibility` + `teamRelevance` — see resolveCandidateVisibilityRuling.
|
|
44
150
|
//
|
|
45
151
|
// Passed as `responseFormat: { schema: CANDIDATES_SCHEMA }` to models.generate()
|
|
46
152
|
// so backends that honor structured output (Ollama, OpenAI — verified against
|
|
@@ -64,6 +170,11 @@ export const CANDIDATES_SCHEMA = {
|
|
|
64
170
|
claim: { type: "string" },
|
|
65
171
|
sourceMemoryIds: { type: "array", items: { type: "string" } },
|
|
66
172
|
tags: { type: "array", items: { type: "string" } },
|
|
173
|
+
// flair#1257 slice 3 (continuity runs only — see the module note
|
|
174
|
+
// above CONTINUITY_SCOPE_TAG_PREFIX): an AFFIRMATIVE visibility
|
|
175
|
+
// ruling. Optional for every run; validated when present.
|
|
176
|
+
visibility: { type: "string", enum: ["private", "shared"] },
|
|
177
|
+
teamRelevance: { type: "string" },
|
|
67
178
|
},
|
|
68
179
|
required: ["claim", "sourceMemoryIds"],
|
|
69
180
|
},
|
|
@@ -77,7 +188,39 @@ export const FOCUS_PROMPTS = {
|
|
|
77
188
|
patterns: "Identify recurring patterns across these memories. What themes, approaches, or outcomes appear multiple times? Extract each pattern as a persistent memory.",
|
|
78
189
|
decisions: "Catalog the key decisions made and their outcomes. For each: what was decided, why, and what resulted. Promote important decisions to persistent.",
|
|
79
190
|
errors: "Extract errors, bugs, and failures. For each: what failed, root cause, and fix applied. These are high-value persistent memories.",
|
|
191
|
+
// flair#1257 slice 3 — continuity-journal distillation. The source rows are
|
|
192
|
+
// an agent's auto-captured working-state journal (ephemeral, private,
|
|
193
|
+
// intent-class), not curated knowledge — distill what deserves to OUTLIVE
|
|
194
|
+
// the session. The stale-intent prompt rule here is the PRIMARY layer of
|
|
195
|
+
// the two-layer guard (Kern's ruling); filterStaleSessionIntentCandidates
|
|
196
|
+
// is the testable second layer.
|
|
197
|
+
continuity: "These memories are an agent's short-term session journal: auto-captured working-state deltas (what it was doing, deciding, and why). Distill the DURABLE takeaways — decisions made and their reasons, outcomes, lessons — into atomic persistent memories. Do NOT promote in-flight intent (e.g. \"about to merge X\", \"waiting on Y\", \"going to\", \"planning to\") from a session that is no longer live: the action has since resolved or died, and restating it as current manufactures a false present. Do not promote world-recoverable facts (PR status, CI state) — they are re-observable and go stale.",
|
|
80
198
|
};
|
|
199
|
+
/**
|
|
200
|
+
* Extra execute-mode instruction block for continuity runs (flair#1257 slice
|
|
201
|
+
* 3). Two parts:
|
|
202
|
+
* - the VISIBILITY ruling contract (Sherlock, default-private-unless): the
|
|
203
|
+
* source journal is the most sensitive tier (ephemeral+private), so the
|
|
204
|
+
* promoted claim defaults private; the distiller may rule "shared" only
|
|
205
|
+
* affirmatively, and then MUST justify team-relevance (the justification
|
|
206
|
+
* is recorded on the candidate — resolveCandidateVisibilityRuling drops
|
|
207
|
+
* any shared ruling that arrives without one).
|
|
208
|
+
* - when the session is STALE, an explicit restatement of the stale-intent
|
|
209
|
+
* rule with the session's age class named (the prompt-layer half of the
|
|
210
|
+
* two-layer guard; the post-filter backstops it either way).
|
|
211
|
+
*/
|
|
212
|
+
export function buildContinuityExecuteAddendum(params) {
|
|
213
|
+
const lines = [
|
|
214
|
+
`Continuity visibility rules:`,
|
|
215
|
+
`- Every candidate's visibility defaults to "private". Omit the visibility field unless you are AFFIRMATIVELY ruling a candidate team-relevant.`,
|
|
216
|
+
`- To rule a candidate shared, set visibility: "shared" AND teamRelevance: one sentence stating why teammates need this. A shared ruling without a teamRelevance justification is discarded and the candidate stays private.`,
|
|
217
|
+
`- If uncertain, stay private.`,
|
|
218
|
+
];
|
|
219
|
+
if (params.sessionStale) {
|
|
220
|
+
lines.push(`This session is STALE (its newest journal entry is beyond the staleness horizon): do NOT emit candidates describing in-flight actions ("about to", "waiting on", "going to", "planning to") — those intents have resolved or died. Distill only decisions, outcomes, and lessons.`);
|
|
221
|
+
}
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
81
224
|
/**
|
|
82
225
|
* Shared "Source Memories" block for both prompt mode and execute mode
|
|
83
226
|
* (K&S prompt-injection hardening, spec §3A item 7). Each memory is wrapped
|
|
@@ -127,9 +270,12 @@ For each insight:
|
|
|
127
270
|
* handing a prompt to a human/agent.
|
|
128
271
|
*/
|
|
129
272
|
export function buildExecutePrompt(params) {
|
|
130
|
-
const { agentId, focus, scope, sinceISO, memories } = params;
|
|
273
|
+
const { agentId, focus, scope, sinceISO, memories, continuity } = params;
|
|
131
274
|
const focusText = FOCUS_PROMPTS[focus] ?? FOCUS_PROMPTS.lessons_learned;
|
|
132
275
|
const validIds = memories.map((m) => `"${m.id}"`).join(", ");
|
|
276
|
+
const candidateShape = continuity
|
|
277
|
+
? `{"candidates": [{"claim": string, "sourceMemoryIds": string[], "tags"?: string[], "visibility"?: "shared", "teamRelevance"?: string}]}`
|
|
278
|
+
: `{"candidates": [{"claim": string, "sourceMemoryIds": string[], "tags"?: string[]}]}`;
|
|
133
279
|
return `# Memory Reflection — ${agentId}
|
|
134
280
|
Focus: ${focus}
|
|
135
281
|
Scope: ${scope} (since ${sinceISO})
|
|
@@ -137,13 +283,13 @@ Memories: ${memories.length}
|
|
|
137
283
|
|
|
138
284
|
## Task
|
|
139
285
|
${focusText}
|
|
140
|
-
|
|
286
|
+
${continuity ? `\n${buildContinuityExecuteAddendum(continuity)}\n` : ""}
|
|
141
287
|
## Source Memories
|
|
142
288
|
${buildSourceMemoriesBlock(memories)}
|
|
143
289
|
|
|
144
290
|
## Output
|
|
145
291
|
Respond with ONLY a JSON object of this shape (no prose, no markdown fences):
|
|
146
|
-
{
|
|
292
|
+
${candidateShape}
|
|
147
293
|
Rules:
|
|
148
294
|
- Every sourceMemoryIds entry must be one of: ${validIds || "(none available)"}
|
|
149
295
|
- claim must be a single atomic insight, at most ${MAX_CLAIM_LENGTH} characters
|
|
@@ -219,7 +365,27 @@ export function parseAndValidateCandidates(raw, gatheredMemoryIds) {
|
|
|
219
365
|
}
|
|
220
366
|
tags = candidate.tags;
|
|
221
367
|
}
|
|
222
|
-
|
|
368
|
+
// flair#1257 slice 3: optional visibility ruling fields (continuity runs).
|
|
369
|
+
// Validated for every run — an unknown visibility value must fail closed
|
|
370
|
+
// exactly like any other malformed field, never pass through to a place
|
|
371
|
+
// where "not private" could later read as readable (the free-form-string
|
|
372
|
+
// exact-match lesson). Whether a valid ruling has any EFFECT is decided
|
|
373
|
+
// downstream (resolveCandidateVisibilityRuling, continuity staging only).
|
|
374
|
+
let visibility;
|
|
375
|
+
if (candidate.visibility !== undefined) {
|
|
376
|
+
if (candidate.visibility !== "private" && candidate.visibility !== "shared") {
|
|
377
|
+
return { ok: false, reason: "shape_mismatch" };
|
|
378
|
+
}
|
|
379
|
+
visibility = candidate.visibility;
|
|
380
|
+
}
|
|
381
|
+
let teamRelevance;
|
|
382
|
+
if (candidate.teamRelevance !== undefined) {
|
|
383
|
+
if (typeof candidate.teamRelevance !== "string") {
|
|
384
|
+
return { ok: false, reason: "shape_mismatch" };
|
|
385
|
+
}
|
|
386
|
+
teamRelevance = candidate.teamRelevance;
|
|
387
|
+
}
|
|
388
|
+
candidates.push({ claim: candidate.claim, sourceMemoryIds, tags, visibility, teamRelevance });
|
|
223
389
|
}
|
|
224
390
|
return { ok: true, candidates };
|
|
225
391
|
}
|
|
@@ -310,6 +476,33 @@ export function dedupeCandidates(candidates, existingPendingClaims) {
|
|
|
310
476
|
// rules, not scope selection.
|
|
311
477
|
export function memoryMatchesReflectScope(record, params) {
|
|
312
478
|
const { scope, tag, sinceDate } = params;
|
|
479
|
+
// ── flair#1257 slice 3: continuity-journal containment (both directions) ───
|
|
480
|
+
// The JOURNAL is the ephemeral rows carrying a continuity session tag.
|
|
481
|
+
// Two rules keep it contained:
|
|
482
|
+
//
|
|
483
|
+
// 1. A continuity-tag tagged run gathers THE JOURNAL ONLY — ephemeral
|
|
484
|
+
// rows carrying that session's tag. Promoted rows PRESERVE the
|
|
485
|
+
// session scopeTag (spec item 2), so without the durability bound a
|
|
486
|
+
// re-distill of the same tag would gather its own previous OUTPUTS as
|
|
487
|
+
// input — a distill-of-distilled feedback loop.
|
|
488
|
+
// 2. A journal row is distillable ONLY through its own session's
|
|
489
|
+
// continuity run — the path that carries every slice-3 guard (settle
|
|
490
|
+
// window, continuity focus prompt, stale-intent post-filter, the
|
|
491
|
+
// visibility ruling contract). Without this, the agentId-wide
|
|
492
|
+
// scope:"recent"/"all" gather would sweep a LIVE session's journal
|
|
493
|
+
// into a generic distill, bypassing all of those guards at once (the
|
|
494
|
+
// settle window would be a check that cannot fire).
|
|
495
|
+
//
|
|
496
|
+
// Non-journal rows that carry a continuity tag (the promoted persistent
|
|
497
|
+
// rows) follow the NORMAL scope rules below — they stay re-reflectable
|
|
498
|
+
// like any other durable memory.
|
|
499
|
+
if (scope === "tagged" && isContinuityScopeTag(tag)) {
|
|
500
|
+
return record.durability === "ephemeral" && (record.tags ?? []).includes(tag);
|
|
501
|
+
}
|
|
502
|
+
const rowIsJournal = record.durability === "ephemeral" && (record.tags ?? []).some(isContinuityScopeTag);
|
|
503
|
+
if (rowIsJournal) {
|
|
504
|
+
return false;
|
|
505
|
+
}
|
|
313
506
|
if (scope === "tagged") {
|
|
314
507
|
// No tag ⇒ admit nothing. A tagged reflection with no tag must gather an
|
|
315
508
|
// EMPTY set (fail-closed), never fall through to admitting everything —
|
|
@@ -355,5 +548,9 @@ export function buildStagedCandidateRow(params) {
|
|
|
355
548
|
if (params.scope === "tagged" && typeof params.tag === "string" && params.tag.length > 0) {
|
|
356
549
|
row.scopeTag = params.tag;
|
|
357
550
|
}
|
|
551
|
+
if (params.visibilityRuling) {
|
|
552
|
+
row.visibilityRuling = params.visibilityRuling.ruling;
|
|
553
|
+
row.visibilityRationale = params.visibilityRuling.rationale;
|
|
554
|
+
}
|
|
358
555
|
return row;
|
|
359
556
|
}
|
|
@@ -234,6 +234,159 @@ export function describeExitCode(code) {
|
|
|
234
234
|
return "exit 209 — launchd could not spawn the job (a missing/unwritable log directory produces this)";
|
|
235
235
|
return `exit ${code}`;
|
|
236
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Reads how the job's most recent run ended, from the only vantage that
|
|
239
|
+
* knows: the service manager itself.
|
|
240
|
+
*
|
|
241
|
+
* darwin: `launchctl print` carries `last exit code = N` once a run has
|
|
242
|
+
* completed (parseLaunchdPrintExit). linux: `systemctl --user show` on the
|
|
243
|
+
* service unit — with one trap encoded here rather than in every caller: a
|
|
244
|
+
* unit that has NEVER completed a run still reports `ExecMainStatus=0,
|
|
245
|
+
* Result=success` (systemd property defaults), so the exit properties are
|
|
246
|
+
* only believed when `ExecMainExitTimestampMonotonic` proves a run actually
|
|
247
|
+
* finished. Without that check, "never ran" renders as "last run succeeded"
|
|
248
|
+
* — the exact skipped-check-looks-like-a-pass shape this feature exists to
|
|
249
|
+
* kill.
|
|
250
|
+
*/
|
|
251
|
+
export function queryLastExitStatus(opts) {
|
|
252
|
+
const run = opts.run ?? ((cmd, timeoutMs) => spawnReport(cmd, timeoutMs));
|
|
253
|
+
if (opts.plat === "darwin") {
|
|
254
|
+
const target = opts.darwinTarget;
|
|
255
|
+
if (!target)
|
|
256
|
+
throw new Error("queryLastExitStatus: darwinTarget is required on darwin");
|
|
257
|
+
const printCmd = ["launchctl", "print", target];
|
|
258
|
+
const r = run(printCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
259
|
+
if (spawnedNothing(r)) {
|
|
260
|
+
return { state: "unavailable", exitCode: null, detail: `launchctl could not be run (${printCmd.join(" ")})` };
|
|
261
|
+
}
|
|
262
|
+
if (r.code !== 0) {
|
|
263
|
+
return { state: "unavailable", exitCode: null, detail: `${printCmd.join(" ")} → code ${r.code} (job not loaded — no run record to read)` };
|
|
264
|
+
}
|
|
265
|
+
const { running, lastExitCode } = parseLaunchdPrintExit(r.stdout);
|
|
266
|
+
if (running) {
|
|
267
|
+
return { state: "running", exitCode: null, detail: `${printCmd.join(" ")} → a run is in flight` };
|
|
268
|
+
}
|
|
269
|
+
if (lastExitCode === null) {
|
|
270
|
+
return { state: "never-ran", exitCode: null, detail: `${printCmd.join(" ")} → no completed run recorded` };
|
|
271
|
+
}
|
|
272
|
+
return { state: "recorded", exitCode: lastExitCode, detail: `${printCmd.join(" ")} → last exit code = ${lastExitCode}` };
|
|
273
|
+
}
|
|
274
|
+
const unit = opts.linuxServiceUnit;
|
|
275
|
+
if (!unit)
|
|
276
|
+
throw new Error("queryLastExitStatus: linuxServiceUnit is required on linux");
|
|
277
|
+
const showCmd = ["systemctl", "--user", "show", unit, "--property=ExecMainStatus,Result,ExecMainExitTimestampMonotonic"];
|
|
278
|
+
const r = run(showCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
279
|
+
if (spawnedNothing(r)) {
|
|
280
|
+
return { state: "unavailable", exitCode: null, detail: `systemctl could not be run (${showCmd.join(" ")})` };
|
|
281
|
+
}
|
|
282
|
+
if (/failed to connect to bus/i.test(r.stderr)) {
|
|
283
|
+
return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → ${r.stderr.trim()}` };
|
|
284
|
+
}
|
|
285
|
+
if (r.code !== 0) {
|
|
286
|
+
return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → code ${r.code}${r.stderr.trim() ? `: ${r.stderr.trim()}` : ""}` };
|
|
287
|
+
}
|
|
288
|
+
// Believe the exit properties only when a run has actually finished — see
|
|
289
|
+
// the doc comment above for why this must be checked FIRST.
|
|
290
|
+
const ts = /^ExecMainExitTimestampMonotonic=(\d+)\s*$/m.exec(r.stdout);
|
|
291
|
+
if (ts && Number(ts[1]) === 0) {
|
|
292
|
+
return { state: "never-ran", exitCode: null, detail: `${showCmd.join(" ")} → no completed run recorded` };
|
|
293
|
+
}
|
|
294
|
+
const parsed = parseSystemdShowExit(r.stdout);
|
|
295
|
+
if (parsed.execMainStatus === null) {
|
|
296
|
+
return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → no ExecMainStatus in the reply` };
|
|
297
|
+
}
|
|
298
|
+
const resultTxt = parsed.result ? `, Result=${parsed.result}` : "";
|
|
299
|
+
return {
|
|
300
|
+
state: "recorded",
|
|
301
|
+
exitCode: parsed.execMainStatus,
|
|
302
|
+
detail: `${showCmd.join(" ")} → ExecMainStatus=${parsed.execMainStatus}${resultTxt}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Pure decision logic for `flair doctor`'s "Scheduled drivers" section
|
|
307
|
+
* (flair#1278) — extracted so it is unit-testable without spawning
|
|
308
|
+
* launchctl/systemctl, same idiom as formatEnableReport/assessDriver in the
|
|
309
|
+
* scheduler modules and summarizeDoctorRun in the CLI.
|
|
310
|
+
*
|
|
311
|
+
* The three load-bearing rules:
|
|
312
|
+
* - not-enabled is a CHOICE, not a defect: informational marker, never the
|
|
313
|
+
* pass marker, never the fail marker, never an issue (a skipped check
|
|
314
|
+
* must not look like a pass — flair#970's rule applied to schedulers).
|
|
315
|
+
* - a last-run failure IS a defect, reported loud with actor+state+remedy
|
|
316
|
+
* (embed-verify style): the service manager is firing the job, the runs
|
|
317
|
+
* themselves are dying, so the schedule looks alive while nothing is
|
|
318
|
+
* delivered — the #1231 incident shape.
|
|
319
|
+
* - "could not read" is UNVERIFIED, never a pass and never a hard failure
|
|
320
|
+
* — the same discipline as doctor's audit-log and embeddings probes.
|
|
321
|
+
*/
|
|
322
|
+
export function describeScheduledDriverFinding(f) {
|
|
323
|
+
if (!f.installed) {
|
|
324
|
+
return {
|
|
325
|
+
state: "not-enabled",
|
|
326
|
+
icon: "info",
|
|
327
|
+
isIssue: false,
|
|
328
|
+
message: `${f.label}: not enabled`,
|
|
329
|
+
detail: [`Opt-in — enable: ${f.enableCommand}`],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (f.active === false) {
|
|
333
|
+
return {
|
|
334
|
+
state: "degraded",
|
|
335
|
+
icon: "error",
|
|
336
|
+
isIssue: true,
|
|
337
|
+
message: `${f.label}: INSTALLED BUT NOT LOADED — nothing will run it`,
|
|
338
|
+
detail: [
|
|
339
|
+
`The unit files are on disk, but the service manager does not have the job loaded, so it never fires.`,
|
|
340
|
+
`Fix: ${f.enableCommand} # then check: ${f.statusCommand}`,
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (f.active === null) {
|
|
345
|
+
return {
|
|
346
|
+
state: "unverified",
|
|
347
|
+
icon: "warn",
|
|
348
|
+
isIssue: false,
|
|
349
|
+
message: `${f.label}: UNVERIFIED — installed, but whether it is loaded could not be read`,
|
|
350
|
+
detail: [`Querying the service manager was inconclusive. Check: ${f.statusCommand}`],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// Loaded from here down.
|
|
354
|
+
const le = f.lastExit;
|
|
355
|
+
if (!le || le.state === "unavailable") {
|
|
356
|
+
return {
|
|
357
|
+
state: "unverified",
|
|
358
|
+
icon: "warn",
|
|
359
|
+
isIssue: false,
|
|
360
|
+
message: `${f.label}: loaded, but its last-run status could not be read`,
|
|
361
|
+
detail: [...(le ? [le.detail] : []), `Check: ${f.statusCommand}`],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (le.state === "recorded" && le.exitCode !== 0) {
|
|
365
|
+
return {
|
|
366
|
+
state: "degraded",
|
|
367
|
+
icon: "error",
|
|
368
|
+
isIssue: true,
|
|
369
|
+
message: `${f.label} DEGRADED — loaded, but its last run failed (${describeExitCode(le.exitCode)})`,
|
|
370
|
+
detail: [
|
|
371
|
+
`The service manager has the job loaded and is firing it; the runs themselves are failing, so the schedule looks alive while nothing is delivered.`,
|
|
372
|
+
`Check ${f.stderrLogPath}, then: ${f.statusCommand}`,
|
|
373
|
+
],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (le.state === "running") {
|
|
377
|
+
return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (a run is in flight now)`, detail: [] };
|
|
378
|
+
}
|
|
379
|
+
if (le.state === "never-ran") {
|
|
380
|
+
return {
|
|
381
|
+
state: "healthy",
|
|
382
|
+
icon: "ok",
|
|
383
|
+
isIssue: false,
|
|
384
|
+
message: `${f.label}: loaded (no completed run on record yet)`,
|
|
385
|
+
detail: [`Installed and loaded; the service manager has not recorded a completed run since it last (re)loaded the job.`],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (last run: exit 0)`, detail: [] };
|
|
389
|
+
}
|
|
237
390
|
function spawnedNothing(r) {
|
|
238
391
|
return r.code === null && !r.stdout.trim() && !r.stderr.trim();
|
|
239
392
|
}
|
|
@@ -26,10 +26,16 @@ import { detectPlatform as detectPlatformFor, spawnReport, readTemplate as readT
|
|
|
26
26
|
// flair#850's lesson must have exactly one implementation).
|
|
27
27
|
export { interpretActiveResult };
|
|
28
28
|
export const SHIM_PATH_DEFAULT = resolve(homedir(), ".flair", "bin", "flair-rem-nightly");
|
|
29
|
-
|
|
29
|
+
// Unit names, exported (flair#1278) so `flair doctor`'s scheduled-drivers
|
|
30
|
+
// section addresses the same job this module installs — same single-source
|
|
31
|
+
// rule as the federation scheduler's LAUNCHD_LABEL/SYSTEMD_*_UNIT constants.
|
|
32
|
+
export const LAUNCHD_LABEL = "dev.flair.rem.nightly";
|
|
33
|
+
export const SYSTEMD_TIMER_UNIT = "flair-rem-nightly.timer";
|
|
34
|
+
export const SYSTEMD_SERVICE_UNIT = "flair-rem-nightly.service";
|
|
35
|
+
export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
30
36
|
export const SYSTEMD_USER_DIR = resolve(homedir(), ".config", "systemd", "user");
|
|
31
|
-
export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR,
|
|
32
|
-
export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR,
|
|
37
|
+
export const SYSTEMD_TIMER_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_TIMER_UNIT);
|
|
38
|
+
export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, SYSTEMD_SERVICE_UNIT);
|
|
33
39
|
function detectPlatform(override) {
|
|
34
40
|
return detectPlatformFor("REM nightly scheduler", override);
|
|
35
41
|
}
|
|
@@ -113,9 +119,9 @@ function buildSubstitutions(opts, shimPath, flairBin, nodeBin) {
|
|
|
113
119
|
*/
|
|
114
120
|
function activeCheckCommand(plat) {
|
|
115
121
|
if (plat === "darwin") {
|
|
116
|
-
return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}
|
|
122
|
+
return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`];
|
|
117
123
|
}
|
|
118
|
-
return ["systemctl", "--user", "is-active",
|
|
124
|
+
return ["systemctl", "--user", "is-active", SYSTEMD_TIMER_UNIT];
|
|
119
125
|
}
|
|
120
126
|
/**
|
|
121
127
|
* Synchronous active-state check for CLI use (`flair rem nightly status`).
|
|
@@ -369,7 +375,7 @@ export function enableScheduler(opts) {
|
|
|
369
375
|
// remedy — kickstarting on top of it would blur which actor failed.
|
|
370
376
|
firstRun = verifyFirstRun({
|
|
371
377
|
plat,
|
|
372
|
-
darwinTarget: `gui/${process.getuid?.() ?? ""}
|
|
378
|
+
darwinTarget: `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`,
|
|
373
379
|
stderrLogPath,
|
|
374
380
|
});
|
|
375
381
|
}
|
|
@@ -386,7 +392,7 @@ export function enableScheduler(opts) {
|
|
|
386
392
|
const timerContents = renderTemplate(readTemplate(templateRoot, "systemd/flair-rem-nightly.timer.tmpl"), subs);
|
|
387
393
|
writeFileWithDir(servicePath, serviceContents, 0o600);
|
|
388
394
|
writeFileWithDir(timerPath, timerContents, 0o600);
|
|
389
|
-
const loadCommand = ["systemctl", "--user", "enable", "--now",
|
|
395
|
+
const loadCommand = ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT];
|
|
390
396
|
let loadResult;
|
|
391
397
|
let firstRun;
|
|
392
398
|
if (!opts.skipLoad) {
|
|
@@ -396,7 +402,7 @@ export function enableScheduler(opts) {
|
|
|
396
402
|
// Ordering gate (#1231): only after the load exited 0. Starts the
|
|
397
403
|
// SERVICE unit directly (oneshot ⇒ blocks until the run exits) rather
|
|
398
404
|
// than waiting for the nightly timer to fire.
|
|
399
|
-
firstRun = verifyFirstRun({ plat, linuxServiceUnit:
|
|
405
|
+
firstRun = verifyFirstRun({ plat, linuxServiceUnit: SYSTEMD_SERVICE_UNIT, stderrLogPath });
|
|
400
406
|
}
|
|
401
407
|
}
|
|
402
408
|
return {
|
|
@@ -432,7 +438,7 @@ export function disableScheduler(opts = {}) {
|
|
|
432
438
|
}
|
|
433
439
|
const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
|
|
434
440
|
const servicePath = opts.systemdServiceOverride ?? SYSTEMD_SERVICE_PATH;
|
|
435
|
-
const unloadCommand = ["systemctl", "--user", "disable", "--now",
|
|
441
|
+
const unloadCommand = ["systemctl", "--user", "disable", "--now", SYSTEMD_TIMER_UNIT];
|
|
436
442
|
let unloadResult;
|
|
437
443
|
if (existsSync(timerPath) || existsSync(servicePath)) {
|
|
438
444
|
if (!opts.skipUnload) {
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Flair + DeepSeek Harness (zero-code MCP bridge)
|
|
2
|
+
|
|
3
|
+
Give DeepSeek Harness (DSH) sessions persistent, portable memory — no plugin code, just one Cordis overlay wiring [`@tpsdev-ai/flair-mcp`](../packages/flair-mcp) through DSH's first-party MCP bridge, [`@deepseek-ai/dsh-mcp-client`](https://github.com/deepseek-ai/deepseek-harness/tree/master/packages/mcp/mcp-client).
|
|
4
|
+
|
|
5
|
+
> **Verified against DSH as of 2026-08-20** (`deepseek-ai/deepseek-harness`, branch `master`). DSH is a developer preview and its own README promises compatibility-breaking changes. If wiring fails after a DSH upgrade, re-check the config field names against [their MCP client README](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md) before suspecting Flair.
|
|
6
|
+
|
|
7
|
+
The same eleven tools every other MCP client gets ([full table in mcp-clients.md](mcp-clients.md#what-the-mcp-server-exposes)) appear to the model under DSH's server-qualified names: `mcp__flair__memory_search`, `mcp__flair__memory_store`, `mcp__flair__bootstrap`, and so on — the same `mcp__<server>__<tool>` convention Claude Code uses.
|
|
8
|
+
|
|
9
|
+
Two caveats up front, both structural to DSH's bridge (details below):
|
|
10
|
+
|
|
11
|
+
1. **DSH scrubs the ambient environment before spawning MCP servers.** Flair's env vars must be declared in the overlay's `config.env` — exported shell vars will not reliably reach the server.
|
|
12
|
+
2. **Recall on this path is reactive.** The model must *choose* to call the memory tools; DSH's MCP bridge cannot inject Flair context at session start. There is a documented mitigation (a persona nudge), but real auto-inject requires a native DSH plugin — planned as phase 2 of [flair#1289](https://github.com/tpsdev-ai/flair/issues/1289).
|
|
13
|
+
|
|
14
|
+
## Prerequisites
|
|
15
|
+
|
|
16
|
+
Same as every MCP client — a running Flair and an agent identity. Follow [Step 1 of mcp-clients.md](mcp-clients.md#step-1--install-flair-do-once) (install, `flair init`, `flair agent add <id>`, `flair status`). If DSH runs on a machine that cannot see your Flair instance's loopback address, you need a reachable `FLAIR_URL` — see [quickstart-fabric.md](quickstart-fabric.md).
|
|
17
|
+
|
|
18
|
+
DSH spawns the server with `npx`, so the machine running DSH needs Node.js 22+ (Flair's own floor).
|
|
19
|
+
|
|
20
|
+
## The overlay
|
|
21
|
+
|
|
22
|
+
A ready-to-use copy of this file ships in the repo at [`examples/deepseek-harness/flair.cordis.yml`](../examples/deepseek-harness/flair.cordis.yml):
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
- insert:
|
|
26
|
+
- id: memory-flair
|
|
27
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
28
|
+
config:
|
|
29
|
+
serverName: flair
|
|
30
|
+
transport: stdio
|
|
31
|
+
command: npx
|
|
32
|
+
args: ['-y', '@tpsdev-ai/flair-mcp@<version>']
|
|
33
|
+
env:
|
|
34
|
+
FLAIR_AGENT_ID: <agent-id>
|
|
35
|
+
FLAIR_URL: http://127.0.0.1:19926
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Replace the two placeholders before use:
|
|
39
|
+
|
|
40
|
+
- `<version>` — pin the flair-mcp version you intend to run (the one you already have is `flair --version`). The [pinning rationale from mcp-clients.md](mcp-clients.md#step-2--wire-the-mcp-server-into-your-cli) applies with extra force here: DSH re-spawns the command per session, so an unpinned spec re-resolves to whatever is currently on npm every time. Leaving the literal `<version>` in place fails loudly at `npx` — intended.
|
|
41
|
+
- `<agent-id>` — the identity you created with `flair agent add`.
|
|
42
|
+
|
|
43
|
+
`FLAIR_URL` as shown is the local default; point it at your Fabric URL for a remote instance.
|
|
44
|
+
|
|
45
|
+
Apply it for one run:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
dsh web --patch "$PWD/examples/deepseek-harness/flair.cordis.yml"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
To keep it across runs, merge the single `insert` patch into a user patch layer — `$DSH_HOME/profiles/<name>/cordis.patch.yml` for one profile, or `$DSH_HOME/cordis.patch.yml` machine-wide. Merge into an existing file rather than copying over it; it may already carry unrelated patches.
|
|
52
|
+
|
|
53
|
+
DSH's own reference memory overlays prefer a preinstalled binary over a package runner ("DSH starts it but does not run a package manager"). If you want that shape: `npm install -g @tpsdev-ai/flair-mcp@<version>`, then `command: flair-mcp` with no `args`.
|
|
54
|
+
|
|
55
|
+
## Caveat 1 — the bridge scrubs ambient env; declare Flair's env explicitly
|
|
56
|
+
|
|
57
|
+
Before spawning a stdio MCP server, DSH's bridge builds the child environment from a **scrubbed** copy of the parent env: every variable whose name matches `KEY`, `PASSWORD`, `SECRET`, or `TOKEN` (case-insensitive) is dropped, and so is every `DSH_*` variable. The overlay's `config.env` is merged **after** the scrub, so it is the one reliable channel.
|
|
58
|
+
|
|
59
|
+
Concretely for Flair:
|
|
60
|
+
|
|
61
|
+
- `FLAIR_KEY_PATH` contains `KEY` — an exported value is **silently dropped**. If your Ed25519 key is not at the default `~/.flair/keys/<agent>.key`, you must set `FLAIR_KEY_PATH` in `config.env`.
|
|
62
|
+
- `FLAIR_AGENT_ID` and `FLAIR_URL` happen to survive today's scrub pattern, but the pattern is DSH's to change. Declare all three in `config.env` and depend on none of the ambient env.
|
|
63
|
+
|
|
64
|
+
This mirrors the general rule from [mcp-clients.md troubleshooting](mcp-clients.md#troubleshooting) — a client's own env does not propagate to the spawned MCP subprocess unless declared — DSH just enforces it deliberately.
|
|
65
|
+
|
|
66
|
+
## Caveat 2 — recall is reactive on this path
|
|
67
|
+
|
|
68
|
+
DSH's MCP bridge registers **tools** on the model's tool list. That is all it can do: DSH has no first-class memory seam, and the bridge has no way to run `bootstrap` at session start and inject the result into context. Whether memory gets consulted is the model's per-turn decision — identical to the behavior DSH documents for its own reference memory servers.
|
|
69
|
+
|
|
70
|
+
The documented mitigation is a standing prompt nudge. DSH's deployment persona is the `persona` config key on its `system-prompt` row (agent presets can shadow it with a persona row of their own; there is no end-user prompt-editing API — prompt text is config/composition only). Add something like:
|
|
71
|
+
|
|
72
|
+
> At the start of a task, call `mcp__flair__bootstrap` or `mcp__flair__memory_search` to load relevant memory before planning. When you make a decision worth keeping, or the user asks you to remember something, record it with `mcp__flair__memory_store`.
|
|
73
|
+
|
|
74
|
+
This is additive guidance in the shape DSH's own memory examples recommend, and it works — but it is a nudge, not a guarantee. **Honest limitation:** automatic session-start injection (what Flair's Claude Code `SessionStart` hook does) requires a native DSH plugin using their per-turn system-prompt context seam. That is phase 2 of [flair#1289](https://github.com/tpsdev-ai/flair/issues/1289); until it ships, this wiring gives pull-based memory only.
|
|
75
|
+
|
|
76
|
+
## Tools-only bridging
|
|
77
|
+
|
|
78
|
+
DSH bridges MCP **tools** only — Resources and Prompts are explicitly not bridged (a documented DSH limitation, not a Flair one). This costs nothing here: `flair-mcp` is a tools-only server, so its entire surface crosses the bridge.
|
|
79
|
+
|
|
80
|
+
## Verify your wiring
|
|
81
|
+
|
|
82
|
+
Initial tool discovery is asynchronous — wait until the `mcp__flair__*` tools appear in the session's tool list before the first prompt. Then run the write → fresh-session → recall check (the same protocol shape DSH uses to validate its own reference memory servers):
|
|
83
|
+
|
|
84
|
+
1. In DSH session A, ask: *"Remember that my validation drink is lapsang-`<unique suffix>`."* Confirm the model calls `mcp__flair__memory_store` and the tool reports success.
|
|
85
|
+
2. Open DSH session B in the same running Host — do not copy session A's conversation. Ask: *"What is my validation drink? Check memory."* Confirm the model calls `mcp__flair__memory_search` and returns the value.
|
|
86
|
+
3. Still in session B, ask it to *use* the recalled value ("suggest one drink for the meeting"). Confirm the answer builds on it.
|
|
87
|
+
|
|
88
|
+
A new DSH session is enough; a Host restart is not. Because the memory now lives in Flair rather than a local file, the same check also passes *across harnesses*: store in DSH, then `memory_search` from Claude Code or any other [MCP client](mcp-clients.md) pointed at the same Flair instance and agent.
|
|
89
|
+
|
|
90
|
+
## Config reference (the fields this wiring uses)
|
|
91
|
+
|
|
92
|
+
Field names verified against DSH `master` 2026-08-20; [their table](https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/mcp/mcp-client/README.md) is authoritative.
|
|
93
|
+
|
|
94
|
+
| Field | Value here | Notes |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| `serverName` | `flair` | Namespace for tool names (`mcp__flair__*`); must be unique across live instances |
|
|
97
|
+
| `transport` | `stdio` | flair-mcp is a stdio server |
|
|
98
|
+
| `command` / `args` | `npx` / `['-y', '@tpsdev-ai/flair-mcp@<version>']` | Or a preinstalled `flair-mcp` binary |
|
|
99
|
+
| `env` | `FLAIR_AGENT_ID`, `FLAIR_URL`, optionally `FLAIR_KEY_PATH` | Merged after DSH's env scrub — the only reliable channel (see caveat 1) |
|
|
100
|
+
| `toolCallTimeoutMs` | (default 60000) | Per-tool-call timeout; raise it only if slow remote searches genuinely exceed a minute |
|
|
101
|
+
|
|
102
|
+
## Troubleshooting
|
|
103
|
+
|
|
104
|
+
**"FLAIR_AGENT_ID is required" on startup.** The env block is missing or ambient-only — declare it in `config.env` (caveat 1).
|
|
105
|
+
|
|
106
|
+
**Tools never appear.** DSH logs initial connection and discovery failures; by default a failed startup registers no tools rather than failing the plugin. Check `flair status` on the Flair side, and check the DSH logs for the `flair` server's connect errors. A duplicate `serverName: flair` across live instances fails the later instance at load.
|
|
107
|
+
|
|
108
|
+
**`auth_error` on every call.** Identity/key mismatch — and remember that an exported `FLAIR_KEY_PATH` never reaches the server (caveat 1). Re-run `flair agent add <id>` (idempotent) or set `FLAIR_KEY_PATH` in `config.env`.
|
|
109
|
+
|
|
110
|
+
For everything else: [troubleshooting.md](troubleshooting.md).
|
|
@@ -89,6 +89,21 @@ Per the attention-plane spec's K&S-approved refinements, `entities: [String] @in
|
|
|
89
89
|
Existing rows on all three tables simply carry no `entities` — readers must tolerate absence,
|
|
90
90
|
the same pattern already used for `Presence.activityUpdatedAt`. No migration, no backfill.
|
|
91
91
|
|
|
92
|
+
All three fields are reachable from the CLI (flair#1288): `flair memory add`,
|
|
93
|
+
`flair workspace set`, and `flair orgevent` take `--entities <csv>`, a comma-separated list of
|
|
94
|
+
vocabulary strings:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
flair memory add --agent flint --entities "repo:tpsdev-ai/flair,issue:tpsdev-ai/flair#1288" "shipped the entities CLI surface"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The CLI validates each value before writing (a malformed value is rejected with the
|
|
101
|
+
`type:value` format and the valid type list); the server re-validates on every write path
|
|
102
|
+
regardless. The CLI's validator is an inlined copy of this module
|
|
103
|
+
(`src/lib/entity-vocab-cli.ts` — `src/` can't import across the packaging boundary into
|
|
104
|
+
`resources/`), pinned to it by `test/unit/cli-entities-option.test.ts`; the server-side gate
|
|
105
|
+
remains this module alone.
|
|
106
|
+
|
|
92
107
|
`Relationship` gets **no** `entities` field: its `subject`/`object` columns already carry
|
|
93
108
|
free-form entity-reference strings and are already indexed — they're the vocabulary carrier
|
|
94
109
|
for that table. They are lowercased on write today but not yet validated against this
|
package/docs/integrations.md
CHANGED
|
@@ -17,6 +17,7 @@ Where Flair already runs. Each integration shown here is a working surface — t
|
|
|
17
17
|
| **Gemini CLI** | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Standard MCP server |
|
|
18
18
|
| **Antigravity CLI** (`agy`) | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | `~/.gemini/config/mcp_config.json`; pickup by a live `agy` pending verification |
|
|
19
19
|
| **Goose** (block/goose) | [`flair-mcp`](#claude-code-cursor-codex-gemini-cli-continuedev-via-flair-mcp) | MCP config | Goose ships native MCP support |
|
|
20
|
+
| **DeepSeek Harness** (`dsh`) | [`flair-mcp`](deepseek-harness.md) | Cordis overlay | First-party MCP bridge; tools-only, reactive recall — [dedicated page](deepseek-harness.md) |
|
|
20
21
|
| **LangGraph (TS)** | [`langgraph-flair`](#langgraph-typescript) | FlairClient | Drop-in `BaseStore` |
|
|
21
22
|
| **OpenClaw** | [`openclaw-flair`](#openclaw) | Ed25519 | Native plugin + context engine |
|
|
22
23
|
| **n8n** | [`n8n-nodes-flair`](#n8n) | FlairApi credential | Three nodes (chat memory, search, store) |
|