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/bin/peon-mem.mjs +9 -2
- package/dist/compression.js +4 -3
- package/dist/config.d.ts +13 -0
- package/dist/config.js +26 -0
- package/dist/daemon.d.ts +0 -3
- package/dist/daemon.js +12 -137
- package/dist/embedding-store.d.ts +10 -0
- package/dist/embedding-store.js +97 -19
- package/dist/entity-extraction.js +4 -3
- package/dist/global-extraction.js +4 -3
- package/dist/hyde.js +4 -3
- package/dist/logger.d.ts +20 -0
- package/dist/logger.js +73 -6
- package/dist/memory-store.d.ts +5 -1
- package/dist/memory-store.js +158 -12
- package/dist/monitor.js +14 -224
- package/dist/overview.d.ts +18 -6
- package/dist/overview.js +92 -14
- package/dist/processor.d.ts +36 -0
- package/dist/processor.js +85 -2
- package/dist/quality.d.ts +17 -1
- package/dist/quality.js +152 -25
- package/dist/recuration.js +4 -3
- package/dist/reranker.js +4 -3
- package/dist/tools.d.ts +13 -0
- package/dist/tools.js +76 -14
- package/package.json +3 -3
- package/scripts/peon-operations-watch.mjs +80 -0
- package/dist/roundtable.d.ts +0 -104
- package/dist/roundtable.js +0 -787
package/dist/logger.js
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
|
-
import { appendFile, mkdir,
|
|
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
|
|
23
|
-
return
|
|
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(
|
|
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
|
package/dist/memory-store.d.ts
CHANGED
|
@@ -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
|
|
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>;
|
package/dist/memory-store.js
CHANGED
|
@@ -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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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(() => "");
|