engrm 0.2.1 → 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.
@@ -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
@@ -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.1",
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",