peon-mem 1.0.2 β†’ 1.0.4

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
@@ -1,6 +1,6 @@
1
1
  # 🧠 Peon β€” a memory brain for your AI coding agents
2
2
 
3
- [![npm](https://img.shields.io/npm/v/peon-mem)](https://www.npmjs.com/package/peon-mem) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![tests](https://img.shields.io/badge/tests-passing-brightgreen)](test/)
3
+ [![npm](https://img.shields.io/npm/v/peon-mem)](https://www.npmjs.com/package/peon-mem) [![downloads](https://img.shields.io/npm/dm/peon-mem)](https://www.npmjs.com/package/peon-mem) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![tests](https://img.shields.io/badge/tests-passing-brightgreen)](test/) [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-listed-6b4fbb)](https://registry.modelcontextprotocol.io) [![Star on GitHub](https://img.shields.io/github/stars/VineetV2/peon-mem?style=social)](https://github.com/VineetV2/peon-mem)
4
4
 
5
5
  **Local-first, hierarchical, self-improving memory for Claude Code, Codex, and any MCP client.**
6
6
 
@@ -48,6 +48,7 @@ prompt. It runs as a daemon on your machine, and nothing leaves it.
48
48
  *The live monitor: 18k real beliefs rendered as stars. Type to make matching beliefs flare; click one to inspect it.*
49
49
 
50
50
  ![Search flare β€” type a query and matching beliefs light up across every galaxy](docs/assets/search-flare.gif)
51
+
51
52
  *Ask the field: typing "wulver cluster" makes 400+ matching beliefs flare while the rest dim, and the camera flies to them.*
52
53
 
53
54
  ## Why "Peon"?
@@ -80,6 +81,26 @@ The short version: mem0 and Zep are memory platforms for products you build. Peo
80
81
  for the coding agents you already use. It plugs into Claude Code or Codex in about five
81
82
  minutes, and you can watch it think and audit every number it claims.
82
83
 
84
+ ## Your agents share one brain
85
+
86
+ If you run more than one coding agent β€” Claude Code and Codex, say β€” they usually each live in
87
+ their own bubble. Whatever you work out with one is gone when you switch to the other.
88
+
89
+ Point them at the same project and Peon dissolves that wall. Both agents resolve to the same
90
+ `.peon/` brain (Peon canonicalizes the project path, so the hook, a direct MCP call, and Codex
91
+ all land on one store). So the memory flows between them:
92
+
93
+ - Codex works out how your build pipeline runs and records it. Next time you open Claude Code
94
+ in that repo, it's already in the injected context.
95
+ - Claude Code hits a gotcha and files it. Codex sees it the moment it calls `get_context` or
96
+ `search_memory`.
97
+ - The global brain sits above both, so your preferences and rules follow you into every agent,
98
+ in every project.
99
+
100
+ They aren't chatting in real time. It's a shared notebook both write in and both read from, so
101
+ a decision made in one agent shows up in the other without you re-explaining it. One project,
102
+ one memory, however many agents.
103
+
83
104
  ## Quickstart
84
105
 
85
106
  Requirements: Node 20+, macOS or Linux. An [OpenRouter](https://openrouter.ai) API key is
@@ -412,3 +433,12 @@ or send a PR directly.
412
433
  Rules of the house: every retrieval/quality change ships with a test and an eval-ledger run
413
434
  (`npm run eval`); negative results get documented, not deleted; nothing may hard-delete user
414
435
  memory. `npm test` must stay green.
436
+
437
+ ---
438
+
439
+ <div align="center">
440
+
441
+ **If Peon is useful to you, [β˜… star it on GitHub](https://github.com/VineetV2/peon-mem)** β€” it is
442
+ how other people building with coding agents find it.
443
+
444
+ </div>
package/dist/brain.js CHANGED
@@ -53,12 +53,25 @@ export function resolveConflicts(records, now, protectGlobalScope = true) {
53
53
  // to active, loser archived (recoverable).
54
54
  const candidates = records.filter((r) => r.status === "active" || r.status === "conflicted");
55
55
  const conflicts = detectMemoryConflicts(candidates);
56
- if (conflicts.length === 0)
57
- return { records: [...records], actions: [] };
58
56
  const byId = new Map(records.map((r) => [r.id, r]));
59
57
  const archived = new Set();
60
58
  const reactivated = new Set();
61
59
  const actions = [];
60
+ // Every belief that is part of a CURRENTLY-detected conflict. Anything still flagged
61
+ // "conflicted" but not in this set is an orphan β€” the consolidator (or an older, looser
62
+ // detector) benched it, but it no longer collides with anything. Left alone it stays out of
63
+ // recall forever; the July→August backlog was 100+ such orphans. Reactivate them below.
64
+ const inLiveConflict = new Set();
65
+ for (const c of conflicts) {
66
+ inLiveConflict.add(c.leftId);
67
+ inLiveConflict.add(c.rightId);
68
+ }
69
+ for (const r of records) {
70
+ if (r.status === "conflicted" && !inLiveConflict.has(r.id)) {
71
+ reactivated.add(r.id);
72
+ actions.push({ type: "resolve_conflict", detail: `reactivated stale conflict flag on "${r.content.slice(0, 40)}"`, affectedIds: [r.id] });
73
+ }
74
+ }
62
75
  for (const conflict of conflicts) {
63
76
  const left = byId.get(conflict.leftId);
64
77
  const right = byId.get(conflict.rightId);
package/dist/entities.js CHANGED
@@ -18,6 +18,25 @@
18
18
  const SRC_ROOTS = new Set(["src", "lib", "scripts", "test", "tests", "app", "apps", "packages", "dist", "bin"]);
19
19
  const FILE_EXT_RE = /\.(ts|tsx|js|jsx|mjs|cjs|json|md|mdx|html|css|scss|py|ipynb|pdf|txt|yml|yaml|toml|sh|sql|rs|go|java|rb|c|cpp|h)$/i;
20
20
  const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*(?:[.#][A-Za-z_$][\w$]*)*$/;
21
+ // Bare common words that are never useful DOMAIN entities. Without this gate a lowercase token
22
+ // like "not" / "use" / "no" falls through the identifier branch and becomes a domain concept β€”
23
+ // then two unrelated beliefs that merely both contain that word "share an entity" and get
24
+ // false-flagged as conflicting. Only applies to plain lowercase words: file paths, product
25
+ // acronyms (BIRD, DTS-SQL), and code symbols carry caps/digits/separators and never land here.
26
+ const ENTITY_STOPWORDS = new Set([
27
+ "the", "this", "that", "these", "those", "a", "an", "it", "its", "we", "i", "you", "he", "she",
28
+ "they", "them", "our", "your", "their", "his", "her",
29
+ "if", "when", "then", "than", "for", "and", "but", "or", "nor", "so", "to", "in", "on", "of",
30
+ "at", "by", "as", "is", "are", "was", "were", "be", "been", "being", "do", "does", "did", "done",
31
+ "not", "no", "yes", "none", "null", "na", "nan", "true", "false",
32
+ "use", "used", "using", "avoid", "add", "added", "fix", "fixed", "make", "made", "set", "run",
33
+ "ran", "get", "got", "put", "new", "old", "now", "also", "with", "without", "from", "into",
34
+ "enable", "enabled", "disable", "disabled", "allow", "allowed", "deny", "forbidden",
35
+ "required", "optional", "about", "what", "which", "how", "why", "who", "where"
36
+ ]);
37
+ function isEntityStopword(key) {
38
+ return ENTITY_STOPWORDS.has(key.toLowerCase());
39
+ }
21
40
  /** Canonicalize one raw entity string. Returns null for junk (empty, too long, pure noise). */
22
41
  export function canonicalizeEntity(raw) {
23
42
  const s = (raw ?? "").trim().replace(/^[`'"]+|[`'"]+$/g, "").trim();
@@ -55,10 +74,15 @@ export function canonicalizeEntity(raw) {
55
74
  return { key: s.toLowerCase(), name: s, kind: "concept", namespace: "domain" };
56
75
  if (/[A-Z_]/.test(s.slice(1)))
57
76
  return { key: s, name: s, kind: "symbol", namespace: "code" };
58
- // lowercase single token (e.g. "vllm", "ollama") β€” treat as a domain concept
77
+ // lowercase single token (e.g. "vllm", "ollama") β€” treat as a domain concept, unless it's a
78
+ // bare common word ("not", "use", "no") that would only create noise and false conflicts.
79
+ if (isEntityStopword(s))
80
+ return null;
59
81
  return { key: s.toLowerCase(), name: s, kind: "concept", namespace: "domain" };
60
82
  }
61
83
  // Multi-word phrase / proper noun β†’ domain concept.
84
+ if (isEntityStopword(s))
85
+ return null;
62
86
  return { key: s.toLowerCase(), name: s, kind: "concept", namespace: "domain" };
63
87
  }
64
88
  // Common capitalized words that START sentences / clauses β€” not domain entities.
@@ -9,7 +9,12 @@ export class PeonGlobalMemoryStore {
9
9
  this.globalDir = globalDir;
10
10
  }
11
11
  static defaultDirectory() {
12
- return PeonGlobalMemoryStore.defaultGlobalDir;
12
+ // PEON_GLOBAL_DIR relocates the global brain. Tests set it to a temp dir so they never read
13
+ // or write the real global store at ~/Library/Application Support/Peon/global β€” without this
14
+ // a tool created without an explicit globalMemoryDir falls back to the real store, which made
15
+ // cross-project isolation tests flaky and let test runs pollute the user's actual global brain.
16
+ const override = process.env.PEON_GLOBAL_DIR;
17
+ return override && override.trim() ? override.trim() : PeonGlobalMemoryStore.defaultGlobalDir;
13
18
  }
14
19
  static async open(options = {}) {
15
20
  const store = new PeonGlobalMemoryStore(options.globalDir ?? PeonGlobalMemoryStore.defaultDirectory());
package/dist/monitor.js CHANGED
@@ -58,6 +58,8 @@ const CLIENT_SCRIPT = String.raw `
58
58
 
59
59
  function esc(v){ return String(v==null?"":v).replace(/[&<>"']/g,function(c){return {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c];}); }
60
60
  function clip(v,n){ var s=String(v==null?"":v).trim(); return s.length>n?s.slice(0,n)+"…":s; }
61
+ // "summary" β†’ "summaries", "fact" β†’ "facts". Belief type names are user-visible in search results.
62
+ function plural(w,n){ if(n===1) return w; return /(s|x|z|ch|sh)$/.test(w)?w+"es":/[^aeiou]y$/.test(w)?w.slice(0,-1)+"ies":w+"s"; }
61
63
  function fmt(n){ return (Number(n)||0).toLocaleString("en-US"); }
62
64
  function tm(iso){ try{ return new Date(iso).toLocaleTimeString([], {hour:"2-digit",minute:"2-digit",second:"2-digit"}); }catch(e){ return ""; } }
63
65
  function name(p){ return String(p||"").split("/").filter(Boolean).pop()||"project"; }
@@ -316,6 +318,96 @@ const CLIENT_SCRIPT = String.raw `
316
318
  UNI.hits=set;
317
319
  if(hitsEl) hitsEl.textContent=m?(m+" MATCH"+(m>1?"ES":"")):"NO MATCH";
318
320
  if(m){ UNI.tx.x=sx/m; UNI.tx.y=sy/m; UNI.tx.z=Math.max(UNI.cam.z, m<20?1.1:0.55); }
321
+ uniResults(q, toks);
322
+ }
323
+
324
+ /* Readable answer for a query: an EXTRACTIVE summary (no model call β€” works with AI off) plus
325
+ the ranked hits. Flaring stars tell you a match exists; this tells you what it says. */
326
+ function uniResults(q, toks){
327
+ var el=EL("uni-results"); if(!el) return;
328
+ if(!q||!UNI.hits||!UNI.hits.size){ el.hidden=true; el.innerHTML=""; return; }
329
+ // Dedupe by record id: the same belief can be drawn as more than one star (a global belief
330
+ // also appears under a project galaxy), and counting/listing it twice would misreport memory.
331
+ var seen={}, hits=[];
332
+ UNI.nodes.forEach(function(n){
333
+ if(!UNI.hits.has(n.id)||seen[n.id]) return;
334
+ seen[n.id]=1; hits.push(n);
335
+ });
336
+ // Rank: query-term density, then belief strength/importance, then recency.
337
+ hits.forEach(function(n){
338
+ var hay=(n.rec.content+" "+(n.rec.entities||[]).join(" ")).toLowerCase(), d=0;
339
+ toks.forEach(function(t){ var i=0; while((i=hay.indexOf(t,i))>=0){ d++; i+=t.length; } });
340
+ var imp=(n.rec.score&&n.rec.score.importance)||0;
341
+ n._r=d*2 + imp*3 + (n.rec.strength||0) + (n.rec.recallCount||0)*0.2;
342
+ // Current truth outranks history: a superseded/archived phrasing must never sit above the
343
+ // active belief that replaced it (same reasoning as stale-shadow demotion in retrieval).
344
+ if(n.rec.status&&n.rec.status!=="active") n._r-=6;
345
+ if(n.rec.pinned) n._r+=3;
346
+ // Prefer beliefs that ANSWER the question over pointers to where an answer lives: a bare
347
+ // file path matching on its filename is not a summary. Prose types lead; paths sink.
348
+ var c=n.rec.content||"";
349
+ var pathish=/^[~/.]|^[A-Za-z]:\\/.test(c.trim()) && c.trim().split(/\s+/).length<=3;
350
+ if(pathish) n._r-=4;
351
+ if(n.rec.type==="decision"||n.rec.type==="fact"||n.rec.type==="preference") n._r+=1.5;
352
+ });
353
+ hits.sort(function(a,b){ return b._r-a._r; });
354
+
355
+ // Facets: what KIND of memory answered, and where it lives.
356
+ var byType={}, byProj={}, ents={}, oldest=null, newest=null;
357
+ hits.forEach(function(n){
358
+ var r=n.rec;
359
+ byType[r.type]=(byType[r.type]||0)+1;
360
+ byProj[n.cl.name]=(byProj[n.cl.name]||0)+1;
361
+ (r.entities||[]).forEach(function(e){ ents[e]=(ents[e]||0)+1; });
362
+ var t=r.updatedAt||r.createdAt;
363
+ if(t){ if(!oldest||t<oldest) oldest=t; if(!newest||t>newest) newest=t; }
364
+ });
365
+ var topEnts=Object.keys(ents).sort(function(a,b){ return ents[b]-ents[a]; }).slice(0,6);
366
+ var typeStr=Object.keys(byType).sort(function(a,b){ return byType[b]-byType[a]; })
367
+ .map(function(t){ return byType[t]+" "+plural(t.replace(/_/g," "),byType[t]); }).join(" Β· ");
368
+ var projStr=Object.keys(byProj).sort(function(a,b){ return byProj[b]-byProj[a]; }).slice(0,3).join(", ");
369
+
370
+ // The summary line: the single strongest belief, verbatim, plus the shape of the rest.
371
+ // Beliefs recorded against a file often read "<absolute path>: <the actual point>" β€” the path
372
+ // is provenance (kept in the hit list), so lead with the point instead of the filename.
373
+ var lead=String(hits[0].rec.content||"").replace(/^\s*[~/][^\s:]{12,}:\s*/,"");
374
+ var summary='<b>'+esc(clip(lead,260))+'</b>';
375
+ if(hits.length>1) summary+='<br><span style="color:var(--muted)">+ '+(hits.length-1)+' more across '+esc(projStr)+
376
+ (newest?', last updated '+esc(ago(newest)):'')+'.</span>';
377
+
378
+ var facets='<span class="ur-facet">'+esc(typeStr)+'</span>'+
379
+ topEnts.map(function(e){ return '<span class="ur-facet">'+esc(e)+'</span>'; }).join("");
380
+
381
+ var SHOW=12;
382
+ var list=hits.slice(0,SHOW).map(function(n,i){
383
+ var r=n.rec, meta=[];
384
+ if(r.status&&r.status!=="active") meta.push(r.status);
385
+ meta.push(n.cl.name);
386
+ if(r.recallCount) meta.push("recalled "+r.recallCount+"Γ—");
387
+ if(r.updatedAt) meta.push(ago(r.updatedAt));
388
+ return '<button class="ur-hit" data-i="'+i+'">'+
389
+ '<div class="h-t" style="color:'+n.c+'">'+esc(r.type)+'</div>'+
390
+ esc(clip(r.content,180))+
391
+ '<div class="h-m">'+esc(meta.join(" Β· "))+'</div></button>';
392
+ }).join("");
393
+
394
+ el.hidden=false;
395
+ el.innerHTML='<button class="ui-x" id="ur-x">βœ•</button>'+
396
+ '<div class="ur-head">'+hits.length+' belief'+(hits.length>1?"s":"")+' answer "'+esc(clip(q,40))+'"</div>'+
397
+ '<div class="ur-sum">'+summary+'</div>'+
398
+ '<div class="ur-facets">'+facets+'</div>'+
399
+ '<div class="ur-list">'+list+'</div>'+
400
+ (hits.length>SHOW?'<div class="ur-more">showing top '+SHOW+' of '+hits.length+'</div>':"");
401
+
402
+ var x=EL("ur-x"); if(x) x.addEventListener("click",function(){ el.hidden=true; });
403
+ Array.prototype.forEach.call(el.querySelectorAll(".ur-hit"),function(b){
404
+ b.addEventListener("click",function(){
405
+ var n=hits[Number(b.getAttribute("data-i"))];
406
+ if(!n) return;
407
+ uniInspect(n); // full detail + provenance
408
+ UNI.tx.x=n.x; UNI.tx.y=n.y; UNI.tx.z=Math.max(1.6,UNI.cam.z); // fly to the star
409
+ });
410
+ });
319
411
  }
320
412
  function uniInspect(n){
321
413
  var el=EL("uni-inspect"); if(!el) return;
@@ -329,7 +421,19 @@ const CLIENT_SCRIPT = String.raw `
329
421
  '<span class="g">IMP <b>'+pct(r.score&&r.score.importance)+'</b><i class="bar"><i style="width:'+pct(r.score&&r.score.importance)+'%"></i></i></span>'+
330
422
  '<span class="g">CONF <b>'+pct(r.score&&r.score.confidence)+'</b><i class="bar"><i class="b2" style="width:'+pct(r.score&&r.score.confidence)+'%"></i></i></span></div>'+
331
423
  ((r.entities&&r.entities.length)?'<div class="ui-ents">'+r.entities.slice(0,8).map(function(e){return '<span class="ent mono">'+esc(e)+'</span>';}).join("")+'</div>':"")+
332
- '<div class="ui-proj mono">'+esc(n.cl.name)+(r.updatedAt?' Β· '+esc(ago(r.updatedAt)):'')+'</div>'+
424
+ // Provenance: where this belief came from and how it has been used. Answers "why does my
425
+ // agent believe this, and is it still current?" without opening the JSONL by hand.
426
+ '<div class="ui-proj mono">'+(function(){
427
+ var p=[];
428
+ if(r.createdAt) p.push("learned "+ago(r.createdAt));
429
+ if(r.updatedAt&&r.updatedAt!==r.createdAt) p.push("updated "+ago(r.updatedAt));
430
+ if(r.recallCount) p.push("recalled "+r.recallCount+"Γ—"+(r.lastRecalledAt?" (last "+ago(r.lastRecalledAt)+")":""));
431
+ if(r.pinned) p.push("pinned");
432
+ if(r.summarizedBy) p.push("folded into a summary");
433
+ if(r.source&&r.source.reason) p.push("via "+clip(r.source.reason,48));
434
+ if(r.provenance&&r.provenance.ref) p.push("source: "+clip(r.provenance.ref,44));
435
+ return esc(n.cl.name)+(p.length?' Β· '+esc(p.join(" Β· ")):'');
436
+ })()+'</div>'+
333
437
  (n.cl.path?'<button class="btn sm" id="ui-open">OPEN IN MEMORY BANKS β†’</button>':"");
334
438
  var x=EL("ui-x"); if(x) x.addEventListener("click",function(){ uniInspect(null); });
335
439
  var op=EL("ui-open"); if(op) op.addEventListener("click",function(){
@@ -873,6 +977,26 @@ const DOCUMENT = String.raw `<!doctype html>
873
977
  .ui-meta{display:flex; gap:14px; margin-bottom:9px;}
874
978
  .ui-ents{display:flex; flex-wrap:wrap; gap:5px; margin-bottom:10px;}
875
979
  .ui-proj{color:var(--faint); font-size:10px; margin-bottom:11px;}
980
+ /* Search RESULTS panel (right side, mirrors .uni-inspect on the left): the readable answer to
981
+ a query β€” an extractive summary of what memory says, then the ranked hits you can click. */
982
+ .uni-results{position:absolute; top:60px; right:14px; z-index:8; width:360px; max-height:calc(100% - 130px); overflow:auto;
983
+ background:linear-gradient(165deg, rgba(8,26,42,.96), rgba(4,14,26,.96)); border:1px solid var(--cyan); clip-path:var(--cham);
984
+ padding:16px; box-shadow:0 0 34px -8px rgba(89,227,255,.5);}
985
+ .ur-head{font-family:var(--mono); font-size:10px; letter-spacing:.18em; text-transform:uppercase; color:var(--cyan);
986
+ margin-bottom:10px; text-shadow:0 0 10px rgba(89,227,255,.6);}
987
+ .ur-sum{font-size:12px; line-height:1.6; color:var(--ink); border-left:2px solid var(--cyan);
988
+ padding:2px 0 2px 10px; margin-bottom:12px;}
989
+ .ur-sum b{color:var(--cyan-ink);}
990
+ .ur-facets{display:flex; flex-wrap:wrap; gap:5px; margin-bottom:12px;}
991
+ .ur-facet{font-family:var(--mono); font-size:9.5px; letter-spacing:.08em; text-transform:uppercase;
992
+ border:1px solid rgba(89,227,255,.35); color:var(--muted); padding:2px 6px; clip-path:var(--cham);}
993
+ .ur-list{display:flex; flex-direction:column; gap:7px;}
994
+ .ur-hit{text-align:left; width:100%; background:rgba(6,20,34,.7); border:1px solid rgba(89,227,255,.18);
995
+ padding:8px 10px; clip-path:var(--cham); cursor:pointer; color:var(--ink); font-size:11.5px; line-height:1.5;}
996
+ .ur-hit:hover{border-color:var(--cyan); background:rgba(10,30,48,.9);}
997
+ .ur-hit .h-t{font-family:var(--mono); font-size:9px; letter-spacing:.12em; text-transform:uppercase; color:var(--cyan); margin-bottom:3px;}
998
+ .ur-hit .h-m{font-family:var(--mono); font-size:9px; color:var(--faint); margin-top:4px;}
999
+ .ur-more{font-family:var(--mono); font-size:9.5px; color:var(--faint); margin-top:9px; text-align:center;}
876
1000
  .uni-ticker{position:absolute; left:0; right:0; bottom:0; z-index:6; padding:8px 16px 10px;
877
1001
  background:linear-gradient(180deg, transparent, rgba(2,8,16,.9) 40%); font-family:var(--mono); font-size:10px; color:var(--muted);}
878
1002
  .utk{padding:2px 0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;}
@@ -918,6 +1042,7 @@ const DOCUMENT = String.raw `<!doctype html>
918
1042
  <div class="uni-legend" id="uni-legend"></div>
919
1043
  <div class="uni-tip mono" id="uni-tip" hidden></div>
920
1044
  <div class="uni-inspect" id="uni-inspect" hidden></div>
1045
+ <div class="uni-results" id="uni-results" hidden></div>
921
1046
  <div class="uni-ticker" id="uni-ticker"></div>
922
1047
  </div>
923
1048
  <div id="bh-body"></div>
package/dist/quality.js CHANGED
@@ -37,6 +37,13 @@ export function detectMemoryConflicts(records) {
37
37
  const reason = opposingLanguageReason(left.content, right.content);
38
38
  if (!reason)
39
39
  continue;
40
+ // Same-topic gate. A shared entity + one opposing word-pair somewhere in two long beliefs
41
+ // is a weak signal: on a research brain, dozens of beliefs all mention "BIRD" and one says
42
+ // "use X" while an unrelated one says "avoid Y". They don't contradict β€” they're just both
43
+ // about BIRD. Require the two beliefs to actually be discussing the same thing before
44
+ // calling it a conflict: several shared entities, or real content overlap beyond the token.
45
+ if (!sameTopic(left, right))
46
+ continue;
40
47
  conflicts.push({
41
48
  entity,
42
49
  leftId: left.id,
@@ -228,6 +235,47 @@ function sharedEntity(left, right) {
228
235
  }
229
236
  return undefined;
230
237
  }
238
+ const TOPIC_STOP = new Set([
239
+ "the", "a", "an", "and", "or", "but", "to", "of", "in", "on", "for", "with", "is", "are", "was",
240
+ "were", "be", "as", "at", "by", "it", "this", "that", "we", "our", "use", "used", "using", "from",
241
+ "not", "no", "yes", "do", "does", "did", "so", "if", "then", "than", "when", "which", "what"
242
+ ]);
243
+ /** Content tokens (lowercased, β‰₯3 chars, minus stopwords) β€” the topical fingerprint of a belief. */
244
+ function contentTokens(text) {
245
+ const out = new Set();
246
+ for (const raw of (text ?? "").toLowerCase().split(/[^a-z0-9]+/)) {
247
+ if (raw.length >= 3 && !TOPIC_STOP.has(raw))
248
+ out.add(raw);
249
+ }
250
+ return out;
251
+ }
252
+ /**
253
+ * True when two beliefs are actually discussing the same thing β€” the precondition for their
254
+ * opposing language to be a real contradiction rather than a coincidence. Satisfied by either
255
+ * multiple shared entities (a strong same-subject signal) or meaningful content overlap
256
+ * (Jaccard of content tokens above a floor). Prevents "both mention BIRD, one says use / one
257
+ * says avoid, about unrelated things" from being flagged.
258
+ */
259
+ function sameTopic(left, right) {
260
+ const le = new Set(left.entities.map(normalizeEntity));
261
+ const re = new Set(right.entities.map(normalizeEntity));
262
+ let sharedEntities = 0;
263
+ for (const e of le)
264
+ if (re.has(e))
265
+ sharedEntities += 1;
266
+ if (sharedEntities >= 2)
267
+ return true;
268
+ const lt = contentTokens(left.content);
269
+ const rt = contentTokens(right.content);
270
+ if (lt.size === 0 || rt.size === 0)
271
+ return false;
272
+ let inter = 0;
273
+ for (const t of lt)
274
+ if (rt.has(t))
275
+ inter += 1;
276
+ const jaccard = inter / (lt.size + rt.size - inter);
277
+ return jaccard >= 0.25;
278
+ }
231
279
  function opposingLanguageReason(left, right) {
232
280
  const leftText = normalizeMemory(left);
233
281
  const rightText = normalizeMemory(right);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peon-mem",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "dependencies": {
22
22
  "@modelcontextprotocol/sdk": "^1.23.0"
23
23
  },
24
- "description": "Local-first hierarchical memory brain for AI coding agents \u2014 auto-capture, LLM consolidation into beliefs, hybrid retrieval, per-prompt injection, a living Neural Universe monitor, and a daily self-improvement loop. Works with Claude Code, Codex, and any MCP client.",
24
+ "description": "Local-first hierarchical memory brain for AI coding agents β€” auto-capture, LLM consolidation into beliefs, hybrid retrieval, per-prompt injection, a living Neural Universe monitor, and a daily self-improvement loop. Works with Claude Code, Codex, and any MCP client.",
25
25
  "license": "MIT",
26
26
  "keywords": [
27
27
  "memory",
@@ -55,4 +55,4 @@
55
55
  "LICENSE"
56
56
  ],
57
57
  "mcpName": "io.github.VineetV2/peon-mem"
58
- }
58
+ }