@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.
@@ -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
- {"candidates": [{"claim": string, "sourceMemoryIds": string[], "tags"?: string[]}]}
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
- candidates.push({ claim: candidate.claim, sourceMemoryIds, tags });
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
- export const LAUNCHD_PLIST_PATH = resolve(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist");
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, "flair-rem-nightly.timer");
32
- export const SYSTEMD_SERVICE_PATH = resolve(SYSTEMD_USER_DIR, "flair-rem-nightly.service");
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?.() ?? ""}/dev.flair.rem.nightly`];
122
+ return ["launchctl", "print", `gui/${process.getuid?.() ?? ""}/${LAUNCHD_LABEL}`];
117
123
  }
118
- return ["systemctl", "--user", "is-active", "flair-rem-nightly.timer"];
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?.() ?? ""}/dev.flair.rem.nightly`,
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", "flair-rem-nightly.timer"];
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: "flair-rem-nightly.service", stderrLogPath });
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", "flair-rem-nightly.timer"];
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
@@ -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) |