mcp-agents-memory 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,6 +102,8 @@ Claude Desktop / Claude Code / any MCP-aware client:
102
102
 
103
103
  `AGENT_PLATFORM` is recorded as the Curator's harness identity on every memory_add call. The Curator's model is captured per-call (defaulting to the Producer's author_model) — set explicitly via the curator_model argument when an orchestrator saves memories on behalf of a different model (e.g. delegating to a subagent). This avoids the staleness that env-static model values would introduce when /model swaps mid-session.
104
104
 
105
+ `agent_key` (optional): Agent persona key for multi-persona harnesses (OpenClaw, Hermes, Opencode). Single-persona setups can ignore — `AGENT_KEY` env is the default. Applies to `memory_add`, `memory_save_skill`, and `memory_curator_run`.
106
+
105
107
  ### Cross-machine memory
106
108
 
107
109
  On a second computer, run `npm i -g mcp-agents-memory` and `mcp-agents-memory setup` pointing to the **same** `DATABASE_URL`. Memory shares automatically — the database is the source of truth and the MCP server is stateless.
package/build/index.js CHANGED
@@ -116026,22 +116026,21 @@ ${JSON.stringify(toAudit)}`,
116026
116026
  }
116027
116027
  var MODEL_CACHE = /* @__PURE__ */ new Map();
116028
116028
  var PLATFORM_CACHE = /* @__PURE__ */ new Map();
116029
- var DEFAULT_TRUST_WEIGHT = 0.8;
116030
116029
  async function resolvePlatform(name) {
116031
116030
  if (!name) return null;
116032
116031
  const key = name.toLowerCase();
116033
116032
  if (PLATFORM_CACHE.has(key)) return PLATFORM_CACHE.get(key);
116034
- const sel = await db.query("SELECT id, name, trust_weight FROM platforms WHERE LOWER(name) = $1 LIMIT 1", [key]);
116033
+ const sel = await db.query("SELECT id, name FROM platforms WHERE LOWER(name) = $1 LIMIT 1", [key]);
116035
116034
  if (sel.rows.length > 0) {
116036
- const r2 = { id: sel.rows[0].id, name: sel.rows[0].name, trust_weight: parseFloat(sel.rows[0].trust_weight) };
116035
+ const r2 = { id: sel.rows[0].id, name: sel.rows[0].name };
116037
116036
  PLATFORM_CACHE.set(key, r2);
116038
116037
  return r2;
116039
116038
  }
116040
116039
  const ins = await db.query(
116041
- "INSERT INTO platforms (name, trust_weight) VALUES ($1, 1.00) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id, name, trust_weight",
116040
+ "INSERT INTO platforms (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id, name",
116042
116041
  [name]
116043
116042
  );
116044
- const r = { id: ins.rows[0].id, name: ins.rows[0].name, trust_weight: parseFloat(ins.rows[0].trust_weight) };
116043
+ const r = { id: ins.rows[0].id, name: ins.rows[0].name };
116045
116044
  PLATFORM_CACHE.set(key, r);
116046
116045
  console.error(`\u2728 [Librarian] Auto-registered platform "${name}"`);
116047
116046
  return r;
@@ -116052,34 +116051,46 @@ async function resolveModel(name) {
116052
116051
  if (MODEL_CACHE.has(key)) return MODEL_CACHE.get(key);
116053
116052
  try {
116054
116053
  const res = await db.query(
116055
- `SELECT id, model_name, trust_weight
116054
+ `SELECT id, model_name
116056
116055
  FROM models
116057
116056
  WHERE LOWER(model_name) = $1
116058
116057
  OR LOWER(metadata->>'alias') = $1
116059
116058
  LIMIT 1`,
116060
116059
  [key]
116061
116060
  );
116062
- if (res.rows.length === 0) {
116063
- console.error(`\u26A0\uFE0F [Librarian] Unknown model "${name}" \u2014 defaulting trust_weight ${DEFAULT_TRUST_WEIGHT}, author_model_id=NULL`);
116061
+ if (res.rows.length > 0) {
116062
+ const resolved2 = {
116063
+ id: res.rows[0].id,
116064
+ model_name: res.rows[0].model_name
116065
+ };
116066
+ MODEL_CACHE.set(key, resolved2);
116067
+ return resolved2;
116068
+ }
116069
+ const provider = inferProvider(name);
116070
+ if (!provider) {
116071
+ console.error(`\u26A0\uFE0F [Librarian] Unknown model "${name}" \u2014 provider not inferable, author_model_id=NULL`);
116064
116072
  MODEL_CACHE.set(key, null);
116065
116073
  return null;
116066
116074
  }
116075
+ const ins = await db.query(
116076
+ `INSERT INTO models (provider, model_name, metadata)
116077
+ VALUES ($1, $2, '{}'::jsonb)
116078
+ ON CONFLICT (model_name) DO UPDATE SET model_name = EXCLUDED.model_name
116079
+ RETURNING id, model_name`,
116080
+ [provider, name]
116081
+ );
116067
116082
  const resolved = {
116068
- id: res.rows[0].id,
116069
- trust_weight: parseFloat(res.rows[0].trust_weight),
116070
- model_name: res.rows[0].model_name
116083
+ id: ins.rows[0].id,
116084
+ model_name: ins.rows[0].model_name
116071
116085
  };
116072
116086
  MODEL_CACHE.set(key, resolved);
116087
+ console.error(`\u2728 [Librarian] Auto-registered model "${name}" (provider=${provider})`);
116073
116088
  return resolved;
116074
116089
  } catch (err) {
116075
116090
  console.error(`\u274C [Librarian] resolveModel failed for "${name}":`, err);
116076
116091
  return null;
116077
116092
  }
116078
116093
  }
116079
- function computeEffectiveConfidence(confidence, trustWeight) {
116080
- const raw = confidence / 10 * trustWeight;
116081
- return Math.round(raw * 100) / 100;
116082
- }
116083
116094
  async function resolveContradiction(newFact, subjectId, existingEmbedding) {
116084
116095
  try {
116085
116096
  const embedding = existingEmbedding || await generateEmbedding(newFact.content);
@@ -116220,8 +116231,6 @@ async function processBatch(text, subjectId, projectId, rawSource, provenance =
116220
116231
  }
116221
116232
  const resolvedModel = await resolveModel(provenance.author_model);
116222
116233
  const resolvedPlatform = await resolvePlatform(provenance.platform);
116223
- const trustWeight = resolvedModel?.trust_weight ?? DEFAULT_TRUST_WEIGHT;
116224
- const effectiveConfidence = computeEffectiveConfidence(fact.confidence, trustWeight);
116225
116234
  const auditedStatus = auditResult ? tierToStatus(auditResult.validation_tier) : null;
116226
116235
  const validationStatus = auditedStatus ?? (fact.importance > 7 ? "pending" : "valid");
116227
116236
  const insertRes = await db.query(
@@ -116229,8 +116238,8 @@ async function processBatch(text, subjectId, projectId, rawSource, provenance =
116229
116238
  subject_id, project_subject_id, content, fact_type,
116230
116239
  confidence, importance, tags, embedding, validation_status,
116231
116240
  author_model, platform, session_id,
116232
- agent_platform, agent_model,
116233
- author_model_id, platform_id, effective_confidence, source
116241
+ agent_platform, agent_model, agent_curator_id,
116242
+ author_model_id, platform_id, source
116234
116243
  )
116235
116244
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
116236
116245
  RETURNING id`,
@@ -116249,9 +116258,9 @@ async function processBatch(text, subjectId, projectId, rawSource, provenance =
116249
116258
  provenance.session_id,
116250
116259
  provenance.agent_platform,
116251
116260
  provenance.agent_model,
116261
+ provenance.agent_curator_id ?? null,
116252
116262
  resolvedModel?.id ?? null,
116253
116263
  resolvedPlatform?.id ?? null,
116254
- effectiveConfidence,
116255
116264
  provenance.source ?? "librarian"
116256
116265
  ]
116257
116266
  );
@@ -116317,26 +116326,33 @@ var BRANCH_THRESHOLD = 0.7;
116317
116326
  function singletonArray(value) {
116318
116327
  return typeof value === "number" ? [value] : [];
116319
116328
  }
116329
+ function mergeProjectIntoApplicableTo(base, projectKey) {
116330
+ if (!projectKey) return base;
116331
+ if (Array.isArray(base.projects)) return base;
116332
+ return { ...base, projects: [projectKey] };
116333
+ }
116320
116334
  function getPersistedSkillFields(candidate, audit) {
116321
116335
  if (!audit) {
116336
+ const applicable = mergeProjectIntoApplicableTo({}, candidate.project_key);
116322
116337
  return {
116323
116338
  content: candidate.content,
116324
116339
  sources: JSON.stringify([]),
116325
116340
  validationTier: "unvalidated",
116326
- applicableTo: JSON.stringify({}),
116341
+ applicableTo: JSON.stringify(applicable),
116327
116342
  auditMetadata: {}
116328
116343
  };
116329
116344
  }
116345
+ const merged = mergeProjectIntoApplicableTo(audit.applicable_to ?? {}, candidate.project_key);
116330
116346
  return {
116331
116347
  content: audit.reconciled_content,
116332
116348
  sources: JSON.stringify(audit.sources),
116333
116349
  validationTier: audit.validation_tier,
116334
- applicableTo: JSON.stringify(audit.applicable_to ?? {}),
116350
+ applicableTo: JSON.stringify(merged),
116335
116351
  auditMetadata: {
116336
116352
  validation_tier: audit.validation_tier,
116337
116353
  audit_reasoning: audit.audit_reasoning,
116338
116354
  sources: audit.sources,
116339
- applicable_to: audit.applicable_to ?? {}
116355
+ applicable_to: merged
116340
116356
  }
116341
116357
  };
116342
116358
  }
@@ -116374,11 +116390,14 @@ async function getInjectableSkills(ctx) {
116374
116390
  AND
116375
116391
  ($2::text IS NULL OR NOT (applicable_to ? 'platforms')
116376
116392
  OR applicable_to->'platforms' @> to_jsonb($2::text))
116393
+ AND
116394
+ ($3::text IS NULL OR NOT (applicable_to ? 'projects')
116395
+ OR applicable_to->'projects' @> to_jsonb($3::text))
116377
116396
  )
116378
116397
  )
116379
116398
  ORDER BY use_count DESC, last_used_at DESC NULLS LAST, created_at DESC
116380
- LIMIT $3`,
116381
- [ctx.author_model ?? null, ctx.platform ?? null, limit2]
116399
+ LIMIT $4`,
116400
+ [ctx.author_model ?? null, ctx.platform ?? null, ctx.project_key ?? null, limit2]
116382
116401
  );
116383
116402
  return res.rows.map((row) => ({
116384
116403
  id: Number(row.id),
@@ -116398,7 +116417,7 @@ async function recordSkillExposure(skillIds) {
116398
116417
  [skillIds]
116399
116418
  );
116400
116419
  }
116401
- async function updateOrCreateSkill(candidate, audit) {
116420
+ async function updateOrCreateSkill(candidate, audit, agentCuratorId) {
116402
116421
  const persisted = getPersistedSkillFields(candidate, audit);
116403
116422
  const embedding = await generateEmbedding(`${candidate.title}
116404
116423
 
@@ -116418,16 +116437,23 @@ ${persisted.content}`);
116418
116437
  await client2.query(
116419
116438
  `UPDATE skills
116420
116439
  SET use_count = use_count + 1,
116421
- last_used_at = NOW()
116440
+ last_used_at = NOW(),
116441
+ applicable_to = CASE
116442
+ WHEN $2::text IS NULL THEN applicable_to
116443
+ WHEN NOT (applicable_to ? 'projects') THEN applicable_to
116444
+ WHEN applicable_to->'projects' @> to_jsonb($2::text) THEN applicable_to
116445
+ ELSE jsonb_set(applicable_to, '{projects}',
116446
+ applicable_to->'projects' || to_jsonb($2::text))
116447
+ END
116422
116448
  WHERE id = $1`,
116423
- [match.id]
116449
+ [match.id, candidate.project_key ?? null]
116424
116450
  );
116425
116451
  await client2.query(
116426
116452
  `INSERT INTO skill_changelog (
116427
116453
  skill_id, change_type, content_diff, source_memory_ids,
116428
- author_model_id, platform_id, metadata
116454
+ author_model_id, platform_id, metadata, agent_curator_id
116429
116455
  )
116430
- VALUES ($1, 'append', $2, $3, $4, $5, $6)`,
116456
+ VALUES ($1, 'append', $2, $3, $4, $5, $6, $7)`,
116431
116457
  [
116432
116458
  match.id,
116433
116459
  persisted.content,
@@ -116439,7 +116465,8 @@ ${persisted.content}`);
116439
116465
  matched_skill_id: match.id,
116440
116466
  similarity,
116441
116467
  audit: persisted.auditMetadata
116442
- })
116468
+ }),
116469
+ agentCuratorId ?? null
116443
116470
  ]
116444
116471
  );
116445
116472
  await client2.query("COMMIT");
@@ -116481,9 +116508,9 @@ ${persisted.content}`);
116481
116508
  await client2.query(
116482
116509
  `INSERT INTO skill_changelog (
116483
116510
  skill_id, change_type, content_diff, source_memory_ids,
116484
- author_model_id, platform_id, metadata
116511
+ author_model_id, platform_id, metadata, agent_curator_id
116485
116512
  )
116486
- VALUES ($1, 'branched', $2, $3, $4, $5, $6)`,
116513
+ VALUES ($1, 'branched', $2, $3, $4, $5, $6, $7)`,
116487
116514
  [
116488
116515
  skillId2,
116489
116516
  persisted.content,
@@ -116494,7 +116521,8 @@ ${persisted.content}`);
116494
116521
  branched_from_skill_id: match.id,
116495
116522
  similarity,
116496
116523
  audit: persisted.auditMetadata
116497
- })
116524
+ }),
116525
+ agentCuratorId ?? null
116498
116526
  ]
116499
116527
  );
116500
116528
  await client2.query("COMMIT");
@@ -116528,9 +116556,9 @@ ${persisted.content}`);
116528
116556
  await client2.query(
116529
116557
  `INSERT INTO skill_changelog (
116530
116558
  skill_id, change_type, content_diff, source_memory_ids,
116531
- author_model_id, platform_id, metadata
116559
+ author_model_id, platform_id, metadata, agent_curator_id
116532
116560
  )
116533
- VALUES ($1, 'created', $2, $3, $4, $5, $6)`,
116561
+ VALUES ($1, 'created', $2, $3, $4, $5, $6, $7)`,
116534
116562
  [
116535
116563
  skillId,
116536
116564
  persisted.content,
@@ -116541,7 +116569,8 @@ ${persisted.content}`);
116541
116569
  matched_skill_id: match?.id ?? null,
116542
116570
  similarity,
116543
116571
  audit: persisted.auditMetadata
116544
- })
116572
+ }),
116573
+ agentCuratorId ?? null
116545
116574
  ]
116546
116575
  );
116547
116576
  await client2.query("COMMIT");
@@ -116902,7 +116931,10 @@ async function runCurator(options = {}) {
116902
116931
  content: parsed.content,
116903
116932
  source_memory_ids: cluster.member_ids,
116904
116933
  author_model: "curator",
116905
- platform: "system"
116934
+ platform: "system",
116935
+ // v0.8: propagate project scope from cluster context to the skill.
116936
+ // Cross-project clusters (projectKey unset) leave applicable_to.projects unset → match-all.
116937
+ project_key: options.projectKey
116906
116938
  };
116907
116939
  let audit;
116908
116940
  let audited;
@@ -116924,7 +116956,7 @@ async function runCurator(options = {}) {
116924
116956
  }
116925
116957
  let skillResult = null;
116926
116958
  if (!normalized.dryRun) {
116927
- skillResult = await updateOrCreateSkill(candidate, audited);
116959
+ skillResult = await updateOrCreateSkill(candidate, audited, options.agentCuratorId ?? null);
116928
116960
  result.skills_saved++;
116929
116961
  }
116930
116962
  result.candidates.push({
@@ -117355,7 +117387,8 @@ function registerTools(server) {
117355
117387
  inputSchema: {
117356
117388
  user_key: external_exports.string().optional().default(process.env.MEMORY_DEFAULT_SUBJECT || "default_user").describe("User subject key."),
117357
117389
  author_model: external_exports.string().optional().describe("Caller's author model (for skill filtering)."),
117358
- platform: external_exports.string().optional().describe("Caller's platform (for skill filtering).")
117390
+ platform: external_exports.string().optional().describe("Caller's platform (for skill filtering)."),
117391
+ project_key: external_exports.string().optional().describe("Active project key. Skills scoped to specific projects only inject when this matches; unscoped skills (applicable_to.projects unset) inject regardless.")
117359
117392
  }
117360
117393
  },
117361
117394
  async (args) => {
@@ -117456,6 +117489,7 @@ function registerTools(server) {
117456
117489
  const skills = await getInjectableSkills({
117457
117490
  author_model: args.author_model,
117458
117491
  platform: args.platform,
117492
+ project_key: args.project_key,
117459
117493
  limit: 5
117460
117494
  });
117461
117495
  if (skills.length > 0) {
@@ -117511,19 +117545,22 @@ function registerTools(server) {
117511
117545
  project_key: external_exports.string().optional().describe("Project key if relevant."),
117512
117546
  author_model: external_exports.string().optional().describe("Model name or alias (e.g., 'sonnet', 'opus', 'gemini')."),
117513
117547
  curator_model: external_exports.string().optional().describe("Curator model override \u2014 the model running memory_add. Defaults to author_model (Producer == Curator solo case)."),
117548
+ agent_key: external_exports.string().optional().describe("Agent persona key (e.g., 'agent_openclaw_reviewer'). Multi-persona harnesses pass this per-call to differentiate personas in calibration data. Defaults to env AGENT_KEY."),
117514
117549
  platform: external_exports.string().optional().describe("Platform (e.g., 'antigravity', 'claude-code')."),
117515
117550
  session_id: external_exports.string().optional().describe("Unique session identifier.")
117516
117551
  }
117517
117552
  },
117518
117553
  async (args) => {
117519
117554
  const agentPlatform = process.env.AGENT_PLATFORM;
117555
+ const agentKeyRaw = args.agent_key ?? process.env.AGENT_KEY ?? null;
117556
+ const agentCuratorId = agentKeyRaw ? await getOrCreateSubject(agentKeyRaw, "agent") : null;
117520
117557
  const subjectId = await getOrCreateSubject(args.subject_key, "person");
117521
117558
  let projectId = null;
117522
117559
  if (args.project_key) {
117523
117560
  projectId = await getOrCreateSubject(args.project_key, "project");
117524
117561
  }
117525
- const authorModel = args.author_model ?? null;
117526
- const curatorModel = args.curator_model ?? args.author_model ?? null;
117562
+ const authorModel = args.author_model ?? void 0;
117563
+ const curatorModel = args.curator_model ?? args.author_model ?? void 0;
117527
117564
  const platform = args.platform ?? agentPlatform;
117528
117565
  const result = await processBatch(
117529
117566
  args.text,
@@ -117535,6 +117572,7 @@ function registerTools(server) {
117535
117572
  platform,
117536
117573
  agent_platform: agentPlatform,
117537
117574
  agent_model: curatorModel,
117575
+ agent_curator_id: agentCuratorId,
117538
117576
  session_id: args.session_id
117539
117577
  }
117540
117578
  );
@@ -117568,6 +117606,8 @@ function registerTools(server) {
117568
117606
  source_memory_ids: external_exports.array(external_exports.number()).optional().describe("Memory IDs that produced this skill."),
117569
117607
  author_model: external_exports.string().optional().describe("Model name or alias that authored the skill."),
117570
117608
  platform: external_exports.string().optional().describe("Platform where the skill was authored."),
117609
+ project_key: external_exports.string().optional().describe("Project subject key. If set, skill is scoped to this project (only injects when memory_startup gets matching project_key). Omit for cross-project skills."),
117610
+ agent_key: external_exports.string().optional().describe("Agent persona key. See memory_add for details."),
117571
117611
  audit: external_exports.boolean().optional().default(true).describe("Run Skill Auditor before saving. Default: true.")
117572
117612
  }
117573
117613
  },
@@ -117577,10 +117617,13 @@ function registerTools(server) {
117577
117617
  content: args.content,
117578
117618
  source_memory_ids: args.source_memory_ids,
117579
117619
  author_model: args.author_model,
117580
- platform: args.platform
117620
+ platform: args.platform,
117621
+ project_key: args.project_key
117581
117622
  };
117623
+ const agentKeyRaw = args.agent_key ?? process.env.AGENT_KEY ?? null;
117624
+ const agentCuratorId = agentKeyRaw ? await getOrCreateSubject(agentKeyRaw, "agent") : null;
117582
117625
  const audited = args.audit !== false ? await auditSkill(candidate) : void 0;
117583
- const result = await updateOrCreateSkill(candidate, audited);
117626
+ const result = await updateOrCreateSkill(candidate, audited, agentCuratorId);
117584
117627
  const validationTier = audited?.validation_tier ?? "unvalidated";
117585
117628
  const auditReasoning = audited?.audit_reasoning ?? "audit disabled";
117586
117629
  const sourcesCount = audited?.sources.length ?? 0;
@@ -117613,10 +117656,13 @@ function registerTools(server) {
117613
117656
  min_cluster_size: external_exports.number().optional().describe("Minimum memory count per cluster."),
117614
117657
  similarity_threshold: external_exports.number().optional().describe("Minimum cosine similarity for cluster membership."),
117615
117658
  min_importance: external_exports.number().optional().describe("Minimum average importance for accepted clusters."),
117616
- max_clusters: external_exports.number().optional().describe("Maximum clusters to analyze in one run.")
117659
+ max_clusters: external_exports.number().optional().describe("Maximum clusters to analyze in one run."),
117660
+ agent_key: external_exports.string().optional().describe("Agent persona key for the curator caller. Auto-promotion loop passes none (NULL).")
117617
117661
  }
117618
117662
  },
117619
117663
  async (args) => {
117664
+ const agentKeyRaw = args.agent_key ?? process.env.AGENT_KEY ?? null;
117665
+ const agentCuratorId = agentKeyRaw ? await getOrCreateSubject(agentKeyRaw, "agent") : null;
117620
117666
  const result = await runCurator({
117621
117667
  subjectKey: args.subject_key,
117622
117668
  projectKey: args.project_key,
@@ -117624,7 +117670,8 @@ function registerTools(server) {
117624
117670
  minClusterSize: args.min_cluster_size,
117625
117671
  similarityThreshold: args.similarity_threshold,
117626
117672
  minImportance: args.min_importance,
117627
- maxClusters: args.max_clusters
117673
+ maxClusters: args.max_clusters,
117674
+ agentCuratorId
117628
117675
  });
117629
117676
  const lines = [];
117630
117677
  lines.push(`SKILL CURATOR ${result.dry_run ? "DRY RUN" : "RUN"}`);
@@ -117726,7 +117773,7 @@ function registerTools(server) {
117726
117773
  const whereClause = conditions.join(" AND ");
117727
117774
  const results = await db.query(
117728
117775
  `SELECT f.id, f.content, f.fact_type, f.confidence, f.importance, f.tags, f.created_at,
117729
- f.effective_confidence, m.model_name as author_model,
117776
+ m.model_name as author_model,
117730
117777
  subj.display_name as subject_name,
117731
117778
  proj.display_name as project_name,
117732
117779
  CASE WHEN f.embedding IS NOT NULL
@@ -117753,9 +117800,8 @@ function registerTools(server) {
117753
117800
  let formatted = baseContext + "\u{1F9E0} Recalled Facts:\n\n";
117754
117801
  results.rows.forEach((r) => {
117755
117802
  const sim = (r.similarity * 100).toFixed(0);
117756
- const conf = r.effective_confidence ? `${r.effective_confidence} (eff)` : r.confidence;
117757
117803
  const author = r.author_model ? ` | via ${r.author_model}` : "";
117758
- formatted += `[#${r.id}] [${r.fact_type}] (imp: ${r.importance}, conf: ${conf}, sim: ${sim}%${author})
117804
+ formatted += `[#${r.id}] [${r.fact_type}] (imp: ${r.importance}, conf: ${r.confidence}, sim: ${sim}%${author})
117759
117805
  `;
117760
117806
  if (r.subject_name) formatted += `Subject: ${r.subject_name}`;
117761
117807
  if (r.project_name) formatted += ` | Project: ${r.project_name}`;