engrm 0.2.0 → 0.2.2
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 +1 -1
- package/dist/hooks/session-start.js +291 -0
- package/dist/hooks/stop.js +18 -0
- package/package.json +12 -8
package/README.md
CHANGED
|
@@ -259,4 +259,4 @@ See [LICENSE](LICENSE) for full terms.
|
|
|
259
259
|
|
|
260
260
|
---
|
|
261
261
|
|
|
262
|
-
Built by [
|
|
262
|
+
Built by the [Engrm](https://engrm.dev) team, powered by [Candengo Vector](https://www.candengo.com).
|
|
@@ -1409,6 +1409,288 @@ function recommendPacks(stacks, installedPacks) {
|
|
|
1409
1409
|
return recommendations;
|
|
1410
1410
|
}
|
|
1411
1411
|
|
|
1412
|
+
// src/sync/auth.ts
|
|
1413
|
+
function getApiKey(config) {
|
|
1414
|
+
const envKey = process.env.ENGRM_TOKEN;
|
|
1415
|
+
if (envKey && envKey.startsWith("cvk_"))
|
|
1416
|
+
return envKey;
|
|
1417
|
+
if (config.candengo_api_key && config.candengo_api_key.length > 0) {
|
|
1418
|
+
return config.candengo_api_key;
|
|
1419
|
+
}
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1422
|
+
function getBaseUrl(config) {
|
|
1423
|
+
if (config.candengo_url && config.candengo_url.length > 0) {
|
|
1424
|
+
return config.candengo_url;
|
|
1425
|
+
}
|
|
1426
|
+
return null;
|
|
1427
|
+
}
|
|
1428
|
+
function buildSourceId(config, localId, type = "obs") {
|
|
1429
|
+
return `${config.user_id}-${config.device_id}-${type}-${localId}`;
|
|
1430
|
+
}
|
|
1431
|
+
function parseSourceId(sourceId) {
|
|
1432
|
+
const obsIndex = sourceId.lastIndexOf("-obs-");
|
|
1433
|
+
if (obsIndex === -1)
|
|
1434
|
+
return null;
|
|
1435
|
+
const prefix = sourceId.slice(0, obsIndex);
|
|
1436
|
+
const localIdStr = sourceId.slice(obsIndex + 5);
|
|
1437
|
+
const localId = parseInt(localIdStr, 10);
|
|
1438
|
+
if (isNaN(localId))
|
|
1439
|
+
return null;
|
|
1440
|
+
const firstDash = prefix.indexOf("-");
|
|
1441
|
+
if (firstDash === -1)
|
|
1442
|
+
return null;
|
|
1443
|
+
return {
|
|
1444
|
+
userId: prefix.slice(0, firstDash),
|
|
1445
|
+
deviceId: prefix.slice(firstDash + 1),
|
|
1446
|
+
localId
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// src/embeddings/embedder.ts
|
|
1451
|
+
var _available = null;
|
|
1452
|
+
var _pipeline = null;
|
|
1453
|
+
var MODEL_NAME = "Xenova/all-MiniLM-L6-v2";
|
|
1454
|
+
async function embedText(text) {
|
|
1455
|
+
const pipe = await getPipeline();
|
|
1456
|
+
if (!pipe)
|
|
1457
|
+
return null;
|
|
1458
|
+
try {
|
|
1459
|
+
const output = await pipe(text, { pooling: "mean", normalize: true });
|
|
1460
|
+
return new Float32Array(output.data);
|
|
1461
|
+
} catch {
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
function composeEmbeddingText(obs) {
|
|
1466
|
+
const parts = [obs.title];
|
|
1467
|
+
if (obs.narrative)
|
|
1468
|
+
parts.push(obs.narrative);
|
|
1469
|
+
if (obs.facts) {
|
|
1470
|
+
try {
|
|
1471
|
+
const facts = JSON.parse(obs.facts);
|
|
1472
|
+
if (Array.isArray(facts) && facts.length > 0) {
|
|
1473
|
+
parts.push(facts.map((f) => `- ${f}`).join(`
|
|
1474
|
+
`));
|
|
1475
|
+
}
|
|
1476
|
+
} catch {
|
|
1477
|
+
parts.push(obs.facts);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
if (obs.concepts) {
|
|
1481
|
+
try {
|
|
1482
|
+
const concepts = JSON.parse(obs.concepts);
|
|
1483
|
+
if (Array.isArray(concepts) && concepts.length > 0) {
|
|
1484
|
+
parts.push(concepts.join(", "));
|
|
1485
|
+
}
|
|
1486
|
+
} catch {}
|
|
1487
|
+
}
|
|
1488
|
+
return parts.join(`
|
|
1489
|
+
|
|
1490
|
+
`);
|
|
1491
|
+
}
|
|
1492
|
+
async function getPipeline() {
|
|
1493
|
+
if (_pipeline)
|
|
1494
|
+
return _pipeline;
|
|
1495
|
+
if (_available === false)
|
|
1496
|
+
return null;
|
|
1497
|
+
try {
|
|
1498
|
+
const { pipeline } = await import("@xenova/transformers");
|
|
1499
|
+
_pipeline = await pipeline("feature-extraction", MODEL_NAME);
|
|
1500
|
+
_available = true;
|
|
1501
|
+
return _pipeline;
|
|
1502
|
+
} catch (err) {
|
|
1503
|
+
_available = false;
|
|
1504
|
+
console.error(`[engrm] Local embedding model unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
1505
|
+
return null;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
// src/sync/pull.ts
|
|
1510
|
+
var PULL_CURSOR_KEY = "pull_cursor";
|
|
1511
|
+
var MAX_PAGES = 20;
|
|
1512
|
+
async function pullFromVector(db, client, config, limit = 50) {
|
|
1513
|
+
let cursor = db.getSyncState(PULL_CURSOR_KEY) ?? undefined;
|
|
1514
|
+
let totalReceived = 0;
|
|
1515
|
+
let totalMerged = 0;
|
|
1516
|
+
let totalSkipped = 0;
|
|
1517
|
+
for (let page = 0;page < MAX_PAGES; page++) {
|
|
1518
|
+
const response = await client.pullChanges(cursor, limit);
|
|
1519
|
+
const { merged, skipped } = mergeChanges(db, config, response.changes);
|
|
1520
|
+
totalReceived += response.changes.length;
|
|
1521
|
+
totalMerged += merged;
|
|
1522
|
+
totalSkipped += skipped;
|
|
1523
|
+
if (response.cursor) {
|
|
1524
|
+
db.setSyncState(PULL_CURSOR_KEY, response.cursor);
|
|
1525
|
+
cursor = response.cursor;
|
|
1526
|
+
}
|
|
1527
|
+
if (!response.has_more || response.changes.length === 0)
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
return { received: totalReceived, merged: totalMerged, skipped: totalSkipped };
|
|
1531
|
+
}
|
|
1532
|
+
function mergeChanges(db, config, changes) {
|
|
1533
|
+
let merged = 0;
|
|
1534
|
+
let skipped = 0;
|
|
1535
|
+
for (const change of changes) {
|
|
1536
|
+
const parsed = parseSourceId(change.source_id);
|
|
1537
|
+
if (parsed && parsed.deviceId === config.device_id) {
|
|
1538
|
+
skipped++;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const existing = db.db.query("SELECT id FROM observations WHERE remote_source_id = ?").get(change.source_id);
|
|
1542
|
+
if (existing) {
|
|
1543
|
+
skipped++;
|
|
1544
|
+
continue;
|
|
1545
|
+
}
|
|
1546
|
+
const projectCanonical = change.metadata?.project_canonical ?? null;
|
|
1547
|
+
if (!projectCanonical) {
|
|
1548
|
+
skipped++;
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
let project = db.getProjectByCanonicalId(projectCanonical);
|
|
1552
|
+
if (!project) {
|
|
1553
|
+
project = db.upsertProject({
|
|
1554
|
+
canonical_id: projectCanonical,
|
|
1555
|
+
name: change.metadata?.project_name ?? projectCanonical.split("/").pop() ?? "unknown"
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
const obs = db.insertObservation({
|
|
1559
|
+
session_id: change.metadata?.session_id ?? null,
|
|
1560
|
+
project_id: project.id,
|
|
1561
|
+
type: change.metadata?.type ?? "discovery",
|
|
1562
|
+
title: change.metadata?.title ?? change.content.split(`
|
|
1563
|
+
`)[0] ?? "Untitled",
|
|
1564
|
+
narrative: extractNarrative(change.content),
|
|
1565
|
+
facts: change.metadata?.facts ? JSON.stringify(change.metadata.facts) : null,
|
|
1566
|
+
concepts: change.metadata?.concepts ? JSON.stringify(change.metadata.concepts) : null,
|
|
1567
|
+
quality: change.metadata?.quality ?? 0.5,
|
|
1568
|
+
lifecycle: "active",
|
|
1569
|
+
sensitivity: "shared",
|
|
1570
|
+
user_id: change.metadata?.user_id ?? "unknown",
|
|
1571
|
+
device_id: change.metadata?.device_id ?? "unknown",
|
|
1572
|
+
agent: change.metadata?.agent ?? "unknown"
|
|
1573
|
+
});
|
|
1574
|
+
db.db.query("UPDATE observations SET remote_source_id = ? WHERE id = ?").run(change.source_id, obs.id);
|
|
1575
|
+
if (db.vecAvailable) {
|
|
1576
|
+
embedAndInsert(db, obs).catch(() => {});
|
|
1577
|
+
}
|
|
1578
|
+
merged++;
|
|
1579
|
+
}
|
|
1580
|
+
return { merged, skipped };
|
|
1581
|
+
}
|
|
1582
|
+
async function embedAndInsert(db, obs) {
|
|
1583
|
+
const text = composeEmbeddingText(obs);
|
|
1584
|
+
const embedding = await embedText(text);
|
|
1585
|
+
if (embedding)
|
|
1586
|
+
db.vecInsert(obs.id, embedding);
|
|
1587
|
+
}
|
|
1588
|
+
function extractNarrative(content) {
|
|
1589
|
+
const lines = content.split(`
|
|
1590
|
+
`);
|
|
1591
|
+
if (lines.length <= 1)
|
|
1592
|
+
return null;
|
|
1593
|
+
const narrative = lines.slice(1).join(`
|
|
1594
|
+
`).trim();
|
|
1595
|
+
return narrative.length > 0 ? narrative : null;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// src/sync/client.ts
|
|
1599
|
+
class VectorClient {
|
|
1600
|
+
baseUrl;
|
|
1601
|
+
apiKey;
|
|
1602
|
+
siteId;
|
|
1603
|
+
namespace;
|
|
1604
|
+
constructor(config) {
|
|
1605
|
+
const baseUrl = getBaseUrl(config);
|
|
1606
|
+
const apiKey = getApiKey(config);
|
|
1607
|
+
if (!baseUrl || !apiKey) {
|
|
1608
|
+
throw new Error("VectorClient requires candengo_url and candengo_api_key");
|
|
1609
|
+
}
|
|
1610
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
1611
|
+
this.apiKey = apiKey;
|
|
1612
|
+
this.siteId = config.site_id;
|
|
1613
|
+
this.namespace = config.namespace;
|
|
1614
|
+
}
|
|
1615
|
+
static isConfigured(config) {
|
|
1616
|
+
return getApiKey(config) !== null && getBaseUrl(config) !== null;
|
|
1617
|
+
}
|
|
1618
|
+
async ingest(doc) {
|
|
1619
|
+
await this.request("POST", "/v1/ingest", doc);
|
|
1620
|
+
}
|
|
1621
|
+
async batchIngest(docs) {
|
|
1622
|
+
if (docs.length === 0)
|
|
1623
|
+
return;
|
|
1624
|
+
await this.request("POST", "/v1/ingest/batch", { documents: docs });
|
|
1625
|
+
}
|
|
1626
|
+
async search(query, metadataFilter, limit = 10) {
|
|
1627
|
+
const body = { query, limit };
|
|
1628
|
+
if (metadataFilter) {
|
|
1629
|
+
body.metadata_filter = metadataFilter;
|
|
1630
|
+
}
|
|
1631
|
+
return this.request("POST", "/v1/search", body);
|
|
1632
|
+
}
|
|
1633
|
+
async deleteBySourceIds(sourceIds) {
|
|
1634
|
+
if (sourceIds.length === 0)
|
|
1635
|
+
return;
|
|
1636
|
+
await this.request("POST", "/v1/documents/delete", {
|
|
1637
|
+
source_ids: sourceIds
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
async pullChanges(cursor, limit = 50) {
|
|
1641
|
+
const params = new URLSearchParams;
|
|
1642
|
+
if (cursor)
|
|
1643
|
+
params.set("cursor", cursor);
|
|
1644
|
+
params.set("namespace", this.namespace);
|
|
1645
|
+
params.set("limit", String(limit));
|
|
1646
|
+
return this.request("GET", `/v1/sync/changes?${params.toString()}`);
|
|
1647
|
+
}
|
|
1648
|
+
async sendTelemetry(beacon) {
|
|
1649
|
+
await this.request("POST", "/v1/mem/telemetry", beacon);
|
|
1650
|
+
}
|
|
1651
|
+
async health() {
|
|
1652
|
+
try {
|
|
1653
|
+
await this.request("GET", "/health");
|
|
1654
|
+
return true;
|
|
1655
|
+
} catch {
|
|
1656
|
+
return false;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
async request(method, path, body) {
|
|
1660
|
+
const url = `${this.baseUrl}${path}`;
|
|
1661
|
+
const headers = {
|
|
1662
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
1663
|
+
"Content-Type": "application/json"
|
|
1664
|
+
};
|
|
1665
|
+
const init = { method, headers };
|
|
1666
|
+
if (body && method !== "GET") {
|
|
1667
|
+
init.body = JSON.stringify(body);
|
|
1668
|
+
}
|
|
1669
|
+
const response = await fetch(url, init);
|
|
1670
|
+
if (!response.ok) {
|
|
1671
|
+
const text = await response.text().catch(() => "");
|
|
1672
|
+
throw new VectorApiError(response.status, text, path);
|
|
1673
|
+
}
|
|
1674
|
+
if (response.status === 204 || response.headers.get("content-length") === "0") {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
return response.json();
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
class VectorApiError extends Error {
|
|
1682
|
+
status;
|
|
1683
|
+
body;
|
|
1684
|
+
path;
|
|
1685
|
+
constructor(status, body, path) {
|
|
1686
|
+
super(`Vector API error ${status} on ${path}: ${body}`);
|
|
1687
|
+
this.status = status;
|
|
1688
|
+
this.body = body;
|
|
1689
|
+
this.path = path;
|
|
1690
|
+
this.name = "VectorApiError";
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1412
1694
|
// hooks/session-start.ts
|
|
1413
1695
|
async function main() {
|
|
1414
1696
|
const chunks = [];
|
|
@@ -1435,6 +1717,15 @@ async function main() {
|
|
|
1435
1717
|
process.exit(0);
|
|
1436
1718
|
}
|
|
1437
1719
|
try {
|
|
1720
|
+
if (config.sync.enabled && config.candengo_api_key) {
|
|
1721
|
+
try {
|
|
1722
|
+
const client = new VectorClient(config.candengo_url, config.candengo_api_key, config.site_id, config.namespace);
|
|
1723
|
+
const pullResult = await pullFromVector(db, client, config, 50);
|
|
1724
|
+
if (pullResult.merged > 0) {
|
|
1725
|
+
console.error(`Engrm: synced ${pullResult.merged} observation(s) from server`);
|
|
1726
|
+
}
|
|
1727
|
+
} catch {}
|
|
1728
|
+
}
|
|
1438
1729
|
const context = buildSessionContext(db, event.cwd, {
|
|
1439
1730
|
tokenBudget: 800,
|
|
1440
1731
|
scope: config.search.scope
|
package/dist/hooks/stop.js
CHANGED
|
@@ -1074,6 +1074,24 @@ function getBaseUrl(config) {
|
|
|
1074
1074
|
function buildSourceId(config, localId, type = "obs") {
|
|
1075
1075
|
return `${config.user_id}-${config.device_id}-${type}-${localId}`;
|
|
1076
1076
|
}
|
|
1077
|
+
function parseSourceId(sourceId) {
|
|
1078
|
+
const obsIndex = sourceId.lastIndexOf("-obs-");
|
|
1079
|
+
if (obsIndex === -1)
|
|
1080
|
+
return null;
|
|
1081
|
+
const prefix = sourceId.slice(0, obsIndex);
|
|
1082
|
+
const localIdStr = sourceId.slice(obsIndex + 5);
|
|
1083
|
+
const localId = parseInt(localIdStr, 10);
|
|
1084
|
+
if (isNaN(localId))
|
|
1085
|
+
return null;
|
|
1086
|
+
const firstDash = prefix.indexOf("-");
|
|
1087
|
+
if (firstDash === -1)
|
|
1088
|
+
return null;
|
|
1089
|
+
return {
|
|
1090
|
+
userId: prefix.slice(0, firstDash),
|
|
1091
|
+
deviceId: prefix.slice(firstDash + 1),
|
|
1092
|
+
localId
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1077
1095
|
|
|
1078
1096
|
// src/sync/client.ts
|
|
1079
1097
|
class VectorClient {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engrm",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Cross-device, team-shared memory layer for AI coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/server.js",
|
|
@@ -25,13 +25,16 @@
|
|
|
25
25
|
"node": ">=18.0.0"
|
|
26
26
|
},
|
|
27
27
|
"keywords": [
|
|
28
|
-
"
|
|
29
|
-
"memory",
|
|
30
|
-
"claude",
|
|
31
|
-
"coding-agent",
|
|
28
|
+
"engrm",
|
|
29
|
+
"ai-memory",
|
|
30
|
+
"claude-code",
|
|
32
31
|
"mcp",
|
|
33
|
-
"
|
|
34
|
-
"
|
|
32
|
+
"coding-agent",
|
|
33
|
+
"context-injection",
|
|
34
|
+
"sentinel",
|
|
35
|
+
"code-audit",
|
|
36
|
+
"cross-device",
|
|
37
|
+
"team-memory",
|
|
35
38
|
"developer-tools"
|
|
36
39
|
],
|
|
37
40
|
"dependencies": {
|
|
@@ -46,8 +49,9 @@
|
|
|
46
49
|
"@types/bun": "^1.2.19",
|
|
47
50
|
"typescript": "^5.8.3"
|
|
48
51
|
},
|
|
52
|
+
"homepage": "https://engrm.dev",
|
|
49
53
|
"license": "FSL-1.1-ALv2",
|
|
50
|
-
"author": "
|
|
54
|
+
"author": "Engrm <hello@engrm.dev> (https://engrm.dev)",
|
|
51
55
|
"repository": {
|
|
52
56
|
"type": "git",
|
|
53
57
|
"url": "https://github.com/unimpossible/candengo-mem"
|