engrm 0.2.1 → 0.2.3

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/cli.js CHANGED
@@ -1075,21 +1075,44 @@ async function openBrowser(url) {
1075
1075
  return false;
1076
1076
  }
1077
1077
  }
1078
+ var PAGE_STYLE = `
1079
+ *{margin:0;padding:0;box-sizing:border-box}
1080
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#06060e;color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
1081
+ body::before{content:'';position:fixed;inset:0;z-index:-1;background:radial-gradient(ellipse at 30% 20%,rgba(0,212,255,0.06) 0%,transparent 50%),radial-gradient(ellipse at 70% 80%,rgba(123,44,191,0.06) 0%,transparent 50%)}
1082
+ .card{width:100%;max-width:440px;padding:48px 40px;border-radius:16px;border:1px solid rgba(255,255,255,0.07);background:rgba(255,255,255,0.02);backdrop-filter:blur(20px);text-align:center}
1083
+ .logo{display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:28px}
1084
+ .logo span{font-size:1.3rem;font-weight:700}
1085
+ .icon{width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 20px}
1086
+ .icon.success{background:rgba(16,185,129,0.12);border:2px solid rgba(16,185,129,0.3)}
1087
+ .icon.error{background:rgba(239,68,68,0.12);border:2px solid rgba(239,68,68,0.3)}
1088
+ h1{font-size:1.4rem;font-weight:700;margin-bottom:8px}
1089
+ p{color:rgba(255,255,255,0.6);font-size:0.9rem;line-height:1.5}
1090
+ .hint{margin-top:24px;padding:12px 16px;font-size:0.82rem;color:rgba(255,255,255,0.4);background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:8px}
1091
+ a{color:#00d4ff;text-decoration:none}
1092
+ `;
1093
+ var LOGO_SVG = `<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:32px;height:32px"><rect width="40" height="40" rx="10" fill="#0c0c1e"/><rect x="0.5" y="0.5" width="39" height="39" rx="9.5" stroke="rgba(255,255,255,0.08)"/><path d="M12 12h10M12 20h8M12 28h10M12 12v16" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="13" cy="7" r="3" fill="#00d4ff"/><circle cx="33" cy="33" r="3" fill="#7b2cbf"/></svg>`;
1078
1094
  function successPage() {
1079
1095
  return `<!DOCTYPE html>
1080
- <html><head><title>Engrm</title>
1081
- <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f8f9fa}
1082
- .card{text-align:center;padding:3rem;border-radius:12px;background:white;box-shadow:0 2px 12px rgba(0,0,0,0.1)}
1083
- h1{color:#10b981;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
1084
- <body><div class="card"><h1>Connected!</h1><p>You can close this tab and return to the terminal.</p></div></body></html>`;
1096
+ <html><head><title>Engrm — Connected</title><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect rx='6' width='32' height='32' fill='%230c0c1e'/><text x='7' y='24' font-family='system-ui' font-weight='700' font-size='22' fill='white'>E</text><circle cx='10' cy='6' r='3' fill='%2300d4ff'/><circle cx='26' cy='26' r='3' fill='%237b2cbf'/></svg>"><style>${PAGE_STYLE}</style></head>
1097
+ <body><div class="card">
1098
+ <div class="logo">${LOGO_SVG}<span>Engrm</span></div>
1099
+ <div class="icon success"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10b981" stroke-width="2.5" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg></div>
1100
+ <h1>Connected!</h1>
1101
+ <p>Your device is now linked to your Engrm account. Memory will sync automatically across all your devices.</p>
1102
+ <div class="hint">You can close this tab and return to the terminal. Your next Claude Code session will have memory.</div>
1103
+ </div></body></html>`;
1085
1104
  }
1086
1105
  function errorPage(message) {
1106
+ const safeMessage = message.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`);
1087
1107
  return `<!DOCTYPE html>
1088
- <html><head><title>Engrm</title>
1089
- <style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f8f9fa}
1090
- .card{text-align:center;padding:3rem;border-radius:12px;background:white;box-shadow:0 2px 12px rgba(0,0,0,0.1)}
1091
- h1{color:#ef4444;margin-bottom:0.5rem}p{color:#6b7280}</style></head>
1092
- <body><div class="card"><h1>Authorization Failed</h1><p>${message}</p></div></body></html>`;
1108
+ <html><head><title>Engrm — Error</title><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect rx='6' width='32' height='32' fill='%230c0c1e'/><text x='7' y='24' font-family='system-ui' font-weight='700' font-size='22' fill='white'>E</text><circle cx='10' cy='6' r='3' fill='%2300d4ff'/><circle cx='26' cy='26' r='3' fill='%237b2cbf'/></svg>"><style>${PAGE_STYLE}</style></head>
1109
+ <body><div class="card">
1110
+ <div class="logo">${LOGO_SVG}<span>Engrm</span></div>
1111
+ <div class="icon error"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></div>
1112
+ <h1>Authorization Failed</h1>
1113
+ <p>${safeMessage}</p>
1114
+ <div class="hint">Try running <code style="color:#00d4ff">engrm init</code> again, or use <code style="color:#00d4ff">engrm init --token=cmt_xxx</code> with a provisioning token from <a href="https://engrm.dev">engrm.dev</a>.</div>
1115
+ </div></body></html>`;
1093
1116
  }
1094
1117
 
1095
1118
  // src/register.ts
@@ -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.3",
4
4
  "description": "Cross-device, team-shared memory layer for AI coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",