stackmem 1.0.24 → 1.0.25

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
@@ -2,9 +2,11 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
+ import { randomInt } from "node:crypto";
5
6
  import { fileURLToPath, pathToFileURL } from "node:url";
6
7
  import { createInterface } from "node:readline/promises";
7
8
  import { analyzeCodebase } from "./ast-index.js";
9
+ import { VAULT_URL } from "./config.js";
8
10
  import { getDeviceId } from "./device.js";
9
11
  import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
10
12
  import { supabase } from "./storage.js";
@@ -43,6 +45,7 @@ function usage() {
43
45
  cm compress [project]
44
46
  cm init <project> <path>
45
47
  cm uninstall [project] [path]
48
+ cm link
46
49
  cm sync
47
50
  cm context [--project <name>] <task>
48
51
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
@@ -130,6 +133,13 @@ function cmdHelp() {
130
133
  Also removes the stackmem entry from ~/.claude.json's mcpServers.
131
134
  [project] defaults to the .stackmem file at <path> if omitted.
132
135
 
136
+ cm link
137
+ Connect this device to your GitHub account in the hosted vault.
138
+ Prints a 6-digit code, then waits for you to enter it at
139
+ stackmem-vault.vercel.app/link while signed in. The code is
140
+ single-use and expires after 10 minutes; polling stops when it
141
+ does. Links are per-device, so run this once on each machine.
142
+
133
143
  cm sync
134
144
  Regenerate .cursorrules and .windsurfrules from the project's
135
145
  current unresolved memories (decay-ranked, capped at a 2000
@@ -1472,6 +1482,73 @@ async function cmdSync(project) {
1472
1482
  nodeFs.writeFileSync(nodePath.join(resolvedPath, ".windsurfrules"), rulesContent, "utf-8");
1473
1483
  console.log("Synced memories to .cursorrules and .windsurfrules");
1474
1484
  }
1485
+ // --- cm link ---------------------------------------------------------------
1486
+ // Mirrors the link_codes.expires_at default in the migration
1487
+ // (supabase/migrations/001_user_devices_and_link_codes.sql in the vault repo).
1488
+ // If that interval changes, change this too — polling past expires_at just
1489
+ // burns requests against a row the API will refuse.
1490
+ const LINK_CODE_TTL_MS = 10 * 60 * 1000;
1491
+ const LINK_POLL_INTERVAL_MS = 3_000;
1492
+ const LINK_CODE_INSERT_ATTEMPTS = 5;
1493
+ const PG_UNIQUE_VIOLATION = "23505";
1494
+ function sleep(ms) {
1495
+ return new Promise((resolve) => setTimeout(resolve, ms));
1496
+ }
1497
+ // randomInt is a CSPRNG and uniform over the range. Math.random() would do
1498
+ // neither, and a link code is a short-lived credential for claiming this
1499
+ // device's memories — a guessable one is a hijack.
1500
+ function generateLinkCode() {
1501
+ return String(randomInt(0, 1_000_000)).padStart(6, "0");
1502
+ }
1503
+ async function cmdLink() {
1504
+ const deviceId = getDeviceId();
1505
+ // A 6-digit space collides rarely, but only unclaimed codes occupy it and
1506
+ // the primary key makes a collision a hard error rather than a silent
1507
+ // overwrite — so retry with a fresh code instead of failing the command.
1508
+ let code = null;
1509
+ for (let attempt = 0; attempt < LINK_CODE_INSERT_ATTEMPTS; attempt++) {
1510
+ const candidate = generateLinkCode();
1511
+ const { error } = await supabase
1512
+ .from("link_codes")
1513
+ .insert({ code: candidate, device_id: deviceId });
1514
+ if (!error) {
1515
+ code = candidate;
1516
+ break;
1517
+ }
1518
+ if (error.code !== PG_UNIQUE_VIOLATION) {
1519
+ fail(`Failed to create a link code: ${error.message}`);
1520
+ }
1521
+ }
1522
+ if (!code) {
1523
+ fail("Could not get an unused link code. Try again in a minute.");
1524
+ }
1525
+ console.log(`Go to ${VAULT_URL}/link and enter code: ${code}`);
1526
+ const deadline = Date.now() + LINK_CODE_TTL_MS;
1527
+ while (Date.now() < deadline) {
1528
+ await sleep(LINK_POLL_INTERVAL_MS);
1529
+ const { data, error } = await supabase
1530
+ .from("link_codes")
1531
+ .select("claimed_at")
1532
+ .eq("code", code)
1533
+ .maybeSingle();
1534
+ // A transient read failure shouldn't abandon a link the user may have
1535
+ // already completed in the browser — keep polling until the code expires.
1536
+ if (error)
1537
+ continue;
1538
+ // The row is gone: either housekeeping swept it or it was never visible
1539
+ // to this device. Either way there's nothing left to claim.
1540
+ if (!data)
1541
+ break;
1542
+ if (data.claimed_at) {
1543
+ console.log("Device linked to your GitHub account.");
1544
+ // Best-effort cleanup — the code is spent either way, and the API
1545
+ // rejects claimed codes, so a failure here is harmless.
1546
+ await supabase.from("link_codes").delete().eq("code", code);
1547
+ return;
1548
+ }
1549
+ }
1550
+ console.log("Link code expired. Run cm link again.");
1551
+ }
1475
1552
  // --- cm context ------------------------------------------------------------
1476
1553
  const CONTEXT_TYPE_ORDER = ["decision", "constraint", "rejection", "discovery"];
1477
1554
  const CONTEXT_TOP_N = 5;
@@ -2065,6 +2142,12 @@ async function main() {
2065
2142
  await cmdInit(project, targetPath);
2066
2143
  break;
2067
2144
  }
2145
+ case "link": {
2146
+ if (args.length > 0)
2147
+ usage();
2148
+ await cmdLink();
2149
+ break;
2150
+ }
2068
2151
  case "sync": {
2069
2152
  if (args.length > 0)
2070
2153
  usage();
package/dist/config.js CHANGED
@@ -1,2 +1,5 @@
1
+ // Where the hosted vault lives. `cm link` prints a URL under this origin for
2
+ // the user to open in a browser, so it has to be the deployed domain.
3
+ export const VAULT_URL = "https://stackmem-vault.vercel.app";
1
4
  export const SUPABASE_URL = "https://vuplpnwwygojkwxwsnip.supabase.co";
2
5
  export const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ1cGxwbnd3eWdvamt3eHdzbmlwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTA0NjU2MjIsImV4cCI6MjEwNjA0MTYyMn0.gIIWuALWlOAzKRD3AAQVHnCNq-o4mVOHOgHJKc0fmYs";
package/dist/storage.js CHANGED
@@ -51,3 +51,9 @@ export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
51
51
  //
52
52
  // create index memory_links_source_id_idx on memory_links(source_id);
53
53
  // create index memory_links_target_id_idx on memory_links(target_id);
54
+ // `cm link` (src/cli.ts) writes to a link_codes table, and the vault pairs
55
+ // devices to GitHub accounts via user_devices. The vault owns that schema —
56
+ // run supabase/migrations/001_user_devices_and_link_codes.sql from the
57
+ // stackmem-vault repo. link_codes is RLS-scoped to the same 'x-device-id'
58
+ // header as the policies above, so a device can create and poll only its own
59
+ // codes; the vault reads them with the service-role key.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stackmem",
3
- "version": "1.0.24",
3
+ "version": "1.0.25",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -2,9 +2,11 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
+ import { randomInt } from "node:crypto";
5
6
  import { fileURLToPath, pathToFileURL } from "node:url";
6
7
  import { createInterface } from "node:readline/promises";
7
8
  import { analyzeCodebase } from "./ast-index.js";
9
+ import { VAULT_URL } from "./config.js";
8
10
  import { getDeviceId } from "./device.js";
9
11
  import {
10
12
  applyContextBudget,
@@ -64,6 +66,7 @@ function usage(): never {
64
66
  cm compress [project]
65
67
  cm init <project> <path>
66
68
  cm uninstall [project] [path]
69
+ cm link
67
70
  cm sync
68
71
  cm context [--project <name>] <task>
69
72
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
@@ -152,6 +155,13 @@ function cmdHelp() {
152
155
  Also removes the stackmem entry from ~/.claude.json's mcpServers.
153
156
  [project] defaults to the .stackmem file at <path> if omitted.
154
157
 
158
+ cm link
159
+ Connect this device to your GitHub account in the hosted vault.
160
+ Prints a 6-digit code, then waits for you to enter it at
161
+ stackmem-vault.vercel.app/link while signed in. The code is
162
+ single-use and expires after 10 minutes; polling stops when it
163
+ does. Links are per-device, so run this once on each machine.
164
+
155
165
  cm sync
156
166
  Regenerate .cursorrules and .windsurfrules from the project's
157
167
  current unresolved memories (decay-ranked, capped at a 2000
@@ -1689,6 +1699,88 @@ async function cmdSync(project: string) {
1689
1699
  console.log("Synced memories to .cursorrules and .windsurfrules");
1690
1700
  }
1691
1701
 
1702
+ // --- cm link ---------------------------------------------------------------
1703
+
1704
+ // Mirrors the link_codes.expires_at default in the migration
1705
+ // (supabase/migrations/001_user_devices_and_link_codes.sql in the vault repo).
1706
+ // If that interval changes, change this too — polling past expires_at just
1707
+ // burns requests against a row the API will refuse.
1708
+ const LINK_CODE_TTL_MS = 10 * 60 * 1000;
1709
+ const LINK_POLL_INTERVAL_MS = 3_000;
1710
+ const LINK_CODE_INSERT_ATTEMPTS = 5;
1711
+ const PG_UNIQUE_VIOLATION = "23505";
1712
+
1713
+ function sleep(ms: number): Promise<void> {
1714
+ return new Promise((resolve) => setTimeout(resolve, ms));
1715
+ }
1716
+
1717
+ // randomInt is a CSPRNG and uniform over the range. Math.random() would do
1718
+ // neither, and a link code is a short-lived credential for claiming this
1719
+ // device's memories — a guessable one is a hijack.
1720
+ function generateLinkCode(): string {
1721
+ return String(randomInt(0, 1_000_000)).padStart(6, "0");
1722
+ }
1723
+
1724
+ async function cmdLink() {
1725
+ const deviceId = getDeviceId();
1726
+
1727
+ // A 6-digit space collides rarely, but only unclaimed codes occupy it and
1728
+ // the primary key makes a collision a hard error rather than a silent
1729
+ // overwrite — so retry with a fresh code instead of failing the command.
1730
+ let code: string | null = null;
1731
+ for (let attempt = 0; attempt < LINK_CODE_INSERT_ATTEMPTS; attempt++) {
1732
+ const candidate = generateLinkCode();
1733
+ const { error } = await supabase
1734
+ .from("link_codes")
1735
+ .insert({ code: candidate, device_id: deviceId });
1736
+
1737
+ if (!error) {
1738
+ code = candidate;
1739
+ break;
1740
+ }
1741
+ if (error.code !== PG_UNIQUE_VIOLATION) {
1742
+ fail(`Failed to create a link code: ${error.message}`);
1743
+ }
1744
+ }
1745
+
1746
+ if (!code) {
1747
+ fail("Could not get an unused link code. Try again in a minute.");
1748
+ }
1749
+
1750
+ console.log(`Go to ${VAULT_URL}/link and enter code: ${code}`);
1751
+
1752
+ const deadline = Date.now() + LINK_CODE_TTL_MS;
1753
+
1754
+ while (Date.now() < deadline) {
1755
+ await sleep(LINK_POLL_INTERVAL_MS);
1756
+
1757
+ const { data, error } = await supabase
1758
+ .from("link_codes")
1759
+ .select("claimed_at")
1760
+ .eq("code", code)
1761
+ .maybeSingle();
1762
+
1763
+ // A transient read failure shouldn't abandon a link the user may have
1764
+ // already completed in the browser — keep polling until the code expires.
1765
+ if (error) continue;
1766
+
1767
+ // The row is gone: either housekeeping swept it or it was never visible
1768
+ // to this device. Either way there's nothing left to claim.
1769
+ if (!data) break;
1770
+
1771
+ if (data.claimed_at) {
1772
+ console.log("Device linked to your GitHub account.");
1773
+
1774
+ // Best-effort cleanup — the code is spent either way, and the API
1775
+ // rejects claimed codes, so a failure here is harmless.
1776
+ await supabase.from("link_codes").delete().eq("code", code);
1777
+ return;
1778
+ }
1779
+ }
1780
+
1781
+ console.log("Link code expired. Run cm link again.");
1782
+ }
1783
+
1692
1784
  // --- cm context ------------------------------------------------------------
1693
1785
 
1694
1786
  const CONTEXT_TYPE_ORDER: MemoryType[] = ["decision", "constraint", "rejection", "discovery"];
@@ -2338,6 +2430,11 @@ async function main() {
2338
2430
  await cmdInit(project, targetPath);
2339
2431
  break;
2340
2432
  }
2433
+ case "link": {
2434
+ if (args.length > 0) usage();
2435
+ await cmdLink();
2436
+ break;
2437
+ }
2341
2438
  case "sync": {
2342
2439
  if (args.length > 0) usage();
2343
2440
  const project = requireProject(null);
package/src/config.ts CHANGED
@@ -1,3 +1,7 @@
1
+ // Where the hosted vault lives. `cm link` prints a URL under this origin for
2
+ // the user to open in a browser, so it has to be the deployed domain.
3
+ export const VAULT_URL = "https://stackmem-vault.vercel.app";
4
+
1
5
  export const SUPABASE_URL = "https://vuplpnwwygojkwxwsnip.supabase.co";
2
6
  export const SUPABASE_ANON_KEY =
3
7
  "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZ1cGxwbnd3eWdvamt3eHdzbmlwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTA0NjU2MjIsImV4cCI6MjEwNjA0MTYyMn0.gIIWuALWlOAzKRD3AAQVHnCNq-o4mVOHOgHJKc0fmYs";
package/src/storage.ts CHANGED
@@ -55,3 +55,10 @@ export const supabase: SupabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON
55
55
  //
56
56
  // create index memory_links_source_id_idx on memory_links(source_id);
57
57
  // create index memory_links_target_id_idx on memory_links(target_id);
58
+
59
+ // `cm link` (src/cli.ts) writes to a link_codes table, and the vault pairs
60
+ // devices to GitHub accounts via user_devices. The vault owns that schema —
61
+ // run supabase/migrations/001_user_devices_and_link_codes.sql from the
62
+ // stackmem-vault repo. link_codes is RLS-scoped to the same 'x-device-id'
63
+ // header as the policies above, so a device can create and poll only its own
64
+ // codes; the vault reads them with the service-role key.