peon-mem 1.0.7 → 1.0.9

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/logger.js CHANGED
@@ -1,12 +1,32 @@
1
- import { appendFile, mkdir, readFile } from "node:fs/promises";
1
+ import { appendFile, mkdir, open, rename, stat } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
  const DEFAULT_LOG_DIR = join(homedir(), "Library", "Logs", "Peon");
5
+ /**
6
+ * Reads are bounded to the tail of the log. recent() used to slurp the entire
7
+ * file and split it, which meant a months-old 100 MB daemon.jsonl blocked the
8
+ * event loop on every monitor poll (flattening a 100 MB rope + allocating ~400k
9
+ * strings, only to throw all but `limit` of them away). Same tail-read strategy
10
+ * the query-embedding cache already uses.
11
+ *
12
+ * Sized to comfortably cover the largest real caller (token-stat seeding asks
13
+ * for 50k entries; entries average ~260 bytes), so bounding reads does not
14
+ * silently truncate what the daemon rebuilds on boot.
15
+ */
16
+ const DEFAULT_TAIL_BYTES = 16 * 1024 * 1024;
17
+ /** Rotate before the live file can reach a size that hurts anything. */
18
+ const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
5
19
  export class PeonLogger {
6
20
  logFile;
21
+ maxBytes;
22
+ tailBytes;
7
23
  writeQueue = Promise.resolve();
24
+ bytesWritten = 0;
25
+ sizeKnown = false;
8
26
  constructor(options = {}) {
9
27
  this.logFile = join(options.logDir ?? DEFAULT_LOG_DIR, "daemon.jsonl");
28
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
29
+ this.tailBytes = options.tailBytes ?? DEFAULT_TAIL_BYTES;
10
30
  }
11
31
  async log(type, fields = {}) {
12
32
  const entry = {
@@ -18,10 +38,15 @@ export class PeonLogger {
18
38
  await this.enqueueWrite(`${JSON.stringify(entry)}\n`);
19
39
  return entry;
20
40
  }
41
+ /**
42
+ * Newest-first entries from the tail of the log. Cost is bounded by
43
+ * `tailBytes`, not by the size of the file, so this stays flat as the log
44
+ * grows. Entries older than the tail window are not visible here — the log
45
+ * file itself (and its rotated siblings) remain the full record.
46
+ */
21
47
  async recent(limit = 100) {
22
- const raw = await readFile(this.logFile, "utf8").catch(() => "");
23
- return raw
24
- .trim()
48
+ const text = await this.readTail();
49
+ return text
25
50
  .split("\n")
26
51
  .filter(Boolean)
27
52
  .slice(-limit)
@@ -31,15 +56,57 @@ export class PeonLogger {
31
56
  return [JSON.parse(line)];
32
57
  }
33
58
  catch {
59
+ // Skips both genuinely corrupt lines and the partial first line left
60
+ // by seeking into the middle of a record.
34
61
  return [];
35
62
  }
36
63
  });
37
64
  }
65
+ async readTail() {
66
+ let handle;
67
+ try {
68
+ const size = (await stat(this.logFile)).size;
69
+ const length = Math.min(size, this.tailBytes);
70
+ if (length === 0)
71
+ return "";
72
+ handle = await open(this.logFile, "r");
73
+ const buffer = Buffer.alloc(length);
74
+ await handle.read(buffer, 0, length, size - length);
75
+ const text = buffer.toString("utf8");
76
+ // A partial leading line is unavoidable when the window starts mid-record.
77
+ return length < size ? text.slice(text.indexOf("\n") + 1) : text;
78
+ }
79
+ catch {
80
+ return "";
81
+ }
82
+ finally {
83
+ await handle?.close().catch(() => undefined);
84
+ }
85
+ }
86
+ /**
87
+ * Move the live log aside once it exceeds maxBytes. History is preserved in a
88
+ * timestamped sibling rather than truncated, so nothing is lost.
89
+ */
90
+ async rotateIfNeeded() {
91
+ if (this.bytesWritten <= this.maxBytes)
92
+ return;
93
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
94
+ await rename(this.logFile, `${this.logFile}.${stamp}`).catch(() => undefined);
95
+ this.bytesWritten = 0;
96
+ }
38
97
  async enqueueWrite(line) {
39
98
  const write = async () => {
40
99
  try {
41
- await mkdir(this.logFile.slice(0, this.logFile.lastIndexOf("/")), { recursive: true });
100
+ await mkdir(dirname(this.logFile), { recursive: true });
101
+ // Adopt the on-disk size once, so a daemon restart doesn't forget that
102
+ // an already-huge log is due for rotation.
103
+ if (!this.sizeKnown) {
104
+ this.bytesWritten = await stat(this.logFile).then((s) => s.size).catch(() => 0);
105
+ this.sizeKnown = true;
106
+ }
107
+ await this.rotateIfNeeded();
42
108
  await appendFile(this.logFile, line, "utf8");
109
+ this.bytesWritten += Buffer.byteLength(line, "utf8");
43
110
  }
44
111
  catch {
45
112
  // Best-effort logging: a log write failure must never crash the daemon
@@ -164,9 +164,13 @@ export declare class PeonMemoryStore {
164
164
  * No-op when embeddings are unavailable. supersededBy links to a merged-away id
165
165
  * are re-pointed at the surviving record so history stays intact.
166
166
  */
167
- mergeSimilarActiveRecords(records: MemoryRecord[], threshold?: number): Promise<{
167
+ mergeSimilarActiveRecords(records: MemoryRecord[], threshold?: number, options?: {
168
+ maxActive?: number;
169
+ exhaustive?: boolean;
170
+ }): Promise<{
168
171
  records: MemoryRecord[];
169
172
  merged: number;
173
+ comparisons: number;
170
174
  }>;
171
175
  readProcessingState(): Promise<ProcessingState>;
172
176
  writeProcessingState(state: ProcessingState): Promise<void>;
@@ -33,6 +33,78 @@ async function atomicWrite(path, content) {
33
33
  await writeFile(tmp, content, "utf8");
34
34
  await rename(tmp, path);
35
35
  }
36
+ /**
37
+ * Ceiling on how many active records the O(n^2) semantic dedup pass will consider.
38
+ * Override with PEON_DEDUP_MAX_ACTIVE. Set generously enough that ordinary project
39
+ * brains still dedup, low enough that a very large brain can't stall the daemon.
40
+ */
41
+ const DEDUP_MAX_ACTIVE = Number(process.env.PEON_DEDUP_MAX_ACTIVE) > 0
42
+ ? Number(process.env.PEON_DEDUP_MAX_ACTIVE)
43
+ : 25_000;
44
+ /**
45
+ * Deterministic random projections for LSH bucketing of dedup candidates.
46
+ * Seeded so bucketing is reproducible across runs and tests.
47
+ */
48
+ const DEDUP_BANDS = 8;
49
+ const DEDUP_BITS_PER_BAND = 6;
50
+ /** Yield to the event loop every N records so a long pass can't starve the daemon. */
51
+ const DEDUP_YIELD_EVERY = 200;
52
+ /**
53
+ * Hard ceiling on candidates examined per record. Bucketing alone only buys a
54
+ * constant factor when vectors are correlated (buckets fill unevenly), leaving the
55
+ * pass quadratic. Capping candidates makes the work O(n * k) — genuinely linear in
56
+ * brain size — at the cost of occasionally missing a merge in a very crowded bucket.
57
+ * A missed merge leaves a near-duplicate belief; an uncapped pass hangs the daemon.
58
+ */
59
+ const DEDUP_MAX_CANDIDATES = 128;
60
+ const projectionCache = new Map();
61
+ function mulberry32(seed) {
62
+ let a = seed >>> 0;
63
+ return () => {
64
+ a = (a + 0x6d2b79f5) >>> 0;
65
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
66
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
67
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
68
+ };
69
+ }
70
+ function projectionsFor(dim) {
71
+ const cached = projectionCache.get(dim);
72
+ if (cached)
73
+ return cached;
74
+ const rand = mulberry32(0x9e3779b9 ^ dim);
75
+ const planes = [];
76
+ for (let p = 0; p < DEDUP_BANDS * DEDUP_BITS_PER_BAND; p += 1) {
77
+ const plane = new Float32Array(dim);
78
+ for (let d = 0; d < dim; d += 1)
79
+ plane[d] = rand() * 2 - 1;
80
+ planes.push(plane);
81
+ }
82
+ projectionCache.set(dim, planes);
83
+ return planes;
84
+ }
85
+ /**
86
+ * Band keys for a vector: sign bits of random-hyperplane projections, grouped into
87
+ * bands. Two vectors with high cosine similarity agree on most bits, so they collide
88
+ * in at least one band with high probability — letting dedup compare a handful of
89
+ * plausible candidates instead of every record seen so far.
90
+ */
91
+ function bandKeys(vector) {
92
+ const dim = vector.length;
93
+ const planes = projectionsFor(dim);
94
+ const keys = [];
95
+ for (let band = 0; band < DEDUP_BANDS; band += 1) {
96
+ let bits = "";
97
+ for (let b = 0; b < DEDUP_BITS_PER_BAND; b += 1) {
98
+ const plane = planes[band * DEDUP_BITS_PER_BAND + b];
99
+ let dot = 0;
100
+ for (let d = 0; d < dim; d += 1)
101
+ dot += vector[d] * plane[d];
102
+ bits += dot >= 0 ? "1" : "0";
103
+ }
104
+ keys.push(`${band}:${bits}`);
105
+ }
106
+ return keys;
107
+ }
36
108
  export class PeonMemoryStore {
37
109
  projectPath;
38
110
  memoryDir;
@@ -612,18 +684,29 @@ export class PeonMemoryStore {
612
684
  * No-op when embeddings are unavailable. supersededBy links to a merged-away id
613
685
  * are re-pointed at the surviving record so history stays intact.
614
686
  */
615
- async mergeSimilarActiveRecords(records, threshold = 0.9) {
687
+ async mergeSimilarActiveRecords(records, threshold = 0.9, options = {}) {
616
688
  if (!this.embeddingClient || !this.embeddingStore)
617
- return { records, merged: 0 };
689
+ return { records, merged: 0, comparisons: 0 };
690
+ // SCALE GUARD. The pass below is O(n^2) pairwise cosine over 1536-dim vectors.
691
+ // On a 30k-record brain (~6.4k active) that is ~20.5M comparisons / ~31.6B float
692
+ // ops on the main thread, plus every vector resident as float64 — measured at
693
+ // 99% CPU and >1.9 GB RSS, which wedged the daemon's event loop entirely.
694
+ // Above the threshold we skip dedup rather than take the daemon down: a brain
695
+ // that keeps a few near-duplicates is strictly better than a brain that hangs.
696
+ // Checked BEFORE sync() so the vector sidecar is never even loaded.
697
+ const activeCount = records.reduce((n, r) => (r.status === "active" ? n + 1 : n), 0);
698
+ if (activeCount > (options.maxActive ?? DEDUP_MAX_ACTIVE)) {
699
+ return { records, merged: 0, comparisons: 0 };
700
+ }
618
701
  let vectorById;
619
702
  try {
620
703
  vectorById = (await this.embeddingStore.sync(records, this.embeddingClient)).vectorById;
621
704
  }
622
705
  catch {
623
- return { records, merged: 0 };
706
+ return { records, merged: 0, comparisons: 0 };
624
707
  }
625
708
  if (vectorById.size === 0)
626
- return { records, merged: 0 };
709
+ return { records, merged: 0, comparisons: 0 };
627
710
  const active = records.filter((record) => record.status === "active");
628
711
  const passthrough = records.filter((record) => record.status !== "active");
629
712
  const kept = [];
@@ -631,22 +714,81 @@ export class PeonMemoryStore {
631
714
  const remap = new Map();
632
715
  const mergeNow = new Date().toISOString();
633
716
  let merged = 0;
717
+ // Candidate index: band key -> indices into kept[]. Lets each record compare
718
+ // against a few plausible near-duplicates instead of every record so far,
719
+ // turning the old O(n^2) scan into roughly linear work.
720
+ const buckets = new Map();
721
+ const indexKept = (index, vector, type) => {
722
+ if (!vector)
723
+ return;
724
+ for (const key of bandKeys(vector)) {
725
+ const full = `${type}|${key}`;
726
+ const list = buckets.get(full);
727
+ if (list)
728
+ list.push(index);
729
+ else
730
+ buckets.set(full, [index]);
731
+ }
732
+ };
733
+ let comparisons = 0;
734
+ let processed = 0;
634
735
  for (const record of active) {
736
+ // Long passes must never starve the daemon's event loop the way the old
737
+ // fully synchronous scan did.
738
+ processed += 1;
739
+ if (processed % DEDUP_YIELD_EVERY === 0)
740
+ await new Promise((resolve) => setImmediate(resolve));
635
741
  const vec = vectorById.get(record.id);
636
742
  let matchIndex = -1;
637
743
  if (vec) {
638
- for (let i = 0; i < kept.length; i += 1) {
639
- if (kept[i].type !== record.type)
640
- continue;
641
- const other = vectorById.get(kept[i].id);
642
- if (other && cosineSimilarity(vec, other) >= threshold) {
643
- matchIndex = i;
644
- break;
744
+ if (options.exhaustive) {
745
+ for (let i = 0; i < kept.length; i += 1) {
746
+ if (kept[i].type !== record.type)
747
+ continue;
748
+ const other = vectorById.get(kept[i].id);
749
+ if (!other)
750
+ continue;
751
+ comparisons += 1;
752
+ if (cosineSimilarity(vec, other) >= threshold) {
753
+ matchIndex = i;
754
+ break;
755
+ }
756
+ }
757
+ }
758
+ else {
759
+ const seen = new Set();
760
+ let examined = 0;
761
+ for (const key of bandKeys(vec)) {
762
+ const candidates = buckets.get(`${record.type}|${key}`);
763
+ if (!candidates)
764
+ continue;
765
+ // Most recent entries first: a near-duplicate is likeliest among
766
+ // recently-seen beliefs, so a capped scan still finds the common case.
767
+ for (let c = candidates.length - 1; c >= 0; c -= 1) {
768
+ if (examined >= DEDUP_MAX_CANDIDATES)
769
+ break;
770
+ const i = candidates[c];
771
+ if (seen.has(i))
772
+ continue;
773
+ seen.add(i);
774
+ const other = vectorById.get(kept[i].id);
775
+ if (!other)
776
+ continue;
777
+ examined += 1;
778
+ comparisons += 1;
779
+ if (cosineSimilarity(vec, other) >= threshold) {
780
+ matchIndex = i;
781
+ break;
782
+ }
783
+ }
784
+ if (matchIndex !== -1 || examined >= DEDUP_MAX_CANDIDATES)
785
+ break;
645
786
  }
646
787
  }
647
788
  }
648
789
  if (matchIndex === -1) {
649
790
  kept.push(record);
791
+ indexKept(kept.length - 1, vec, record.type);
650
792
  continue;
651
793
  }
652
794
  const other = kept[matchIndex];
@@ -661,6 +803,10 @@ export class PeonMemoryStore {
661
803
  entities: unique([...record.entities, ...other.entities]),
662
804
  updatedAt: record.updatedAt > other.updatedAt ? record.updatedAt : other.updatedAt
663
805
  };
806
+ // The survivor can be the incoming record, so make its vector findable at
807
+ // that slot too — otherwise later near-duplicates could miss the bucket.
808
+ if (canonical.id === record.id)
809
+ indexKept(matchIndex, vec, record.type);
664
810
  remap.set(loser.id, canonical.id);
665
811
  // Recoverable-loser rule: don't destroy the merged-away belief — retire it as superseded,
666
812
  // linked to the survivor. It leaves active recall but its content stays recoverable and
@@ -682,7 +828,7 @@ export class PeonMemoryStore {
682
828
  const fixed = [...passthrough, ...retired].map((record) => record.supersededBy && remap.has(record.supersededBy)
683
829
  ? { ...record, supersededBy: resolveRemap(record.supersededBy) }
684
830
  : record);
685
- return { records: [...kept, ...fixed], merged };
831
+ return { records: [...kept, ...fixed], merged, comparisons };
686
832
  }
687
833
  async readProcessingState() {
688
834
  const raw = await readFile(join(this.memoryDir, "brain", "processing-state.json"), "utf8").catch(() => "");