sticky-note-cli 2.9.2 → 3.1.0

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/mcp-server.js CHANGED
@@ -3,9 +3,10 @@
3
3
  * sticky-note MCP server — stdio-based
4
4
  *
5
5
  * Implements Model Context Protocol (JSON-RPC 2.0 over stdin/stdout)
6
- * with 8 tools for AI assistants:
6
+ * with 9 tools for AI assistants:
7
7
  *
8
8
  * get_session_context(id) — full thread payload by ID
9
+ * get_full_transcript(id) — full verbatim session transcript(s) for a thread
9
10
  * get_stuck_threads() — all stuck threads
10
11
  * search_threads(query) — keyword search across threads
11
12
  * get_audit_trail(...) — query per-user JSONL audit logs
@@ -23,6 +24,7 @@
23
24
 
24
25
  const fs = require("fs");
25
26
  const path = require("path");
27
+ const { execFileSync } = require("child_process");
26
28
 
27
29
  // ──────────────────────────────────────────────
28
30
  // Project root resolution
@@ -36,8 +38,113 @@ for (let i = 0; i < args.length; i++) {
36
38
  }
37
39
  }
38
40
 
41
+ let _cachedStickyDir = null;
39
42
  function stickyDir() {
40
- return path.join(PROJECT_ROOT, ".sticky-note");
43
+ if (_cachedStickyDir) return _cachedStickyDir;
44
+
45
+ // V3 default storage lives at <git-dir>/sticky-note (the sticky-note/data
46
+ // orphan branch's local working copy), not in the project's working tree.
47
+ // Try that first; fall back to the legacy in-tree .sticky-note/ for repos
48
+ // that haven't migrated (or aren't git repos at all).
49
+ try {
50
+ const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
51
+ encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"], cwd: PROJECT_ROOT,
52
+ }).trim();
53
+ const dataBranchDir = path.join(gitDir, "sticky-note");
54
+ if (fs.existsSync(path.join(dataBranchDir, "sticky-note.json"))) {
55
+ _cachedStickyDir = dataBranchDir;
56
+ return _cachedStickyDir;
57
+ }
58
+ } catch (_) {
59
+ // not a git repo, or git unavailable — fall through to legacy path
60
+ }
61
+
62
+ _cachedStickyDir = path.join(PROJECT_ROOT, ".sticky-note");
63
+ return _cachedStickyDir;
64
+ }
65
+
66
+ function getTranscriptPath(threadId) {
67
+ return path.join(stickyDir(), "transcripts", threadId + ".jsonl");
68
+ }
69
+
70
+ // ──────────────────────────────────────────────
71
+ // Cloud config (V3 optional backend)
72
+ // ──────────────────────────────────────────────
73
+
74
+ function readEnvSticky() {
75
+ const envPath = path.join(PROJECT_ROOT, ".env.sticky");
76
+ const result = {};
77
+ if (!fs.existsSync(envPath)) return result;
78
+ for (const line of fs.readFileSync(envPath, "utf-8").split(/\r?\n/)) {
79
+ const trimmed = line.trim();
80
+ if (trimmed.startsWith("STICKY_URL=")) result.STICKY_URL = trimmed.slice("STICKY_URL=".length);
81
+ if (trimmed.startsWith("STICKY_API_KEY=")) result.STICKY_API_KEY = trimmed.slice("STICKY_API_KEY=".length);
82
+ }
83
+ return result;
84
+ }
85
+
86
+ function getCloudConfig() {
87
+ const envFile = readEnvSticky();
88
+ return {
89
+ url: process.env.STICKY_URL || envFile.STICKY_URL || "",
90
+ apiKey: process.env.STICKY_API_KEY || envFile.STICKY_API_KEY || "",
91
+ };
92
+ }
93
+
94
+ function getProjectName() {
95
+ try {
96
+ const remote = execFileSync("git", ["remote", "get-url", "origin"], {
97
+ encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"], cwd: PROJECT_ROOT,
98
+ }).trim();
99
+ const match = remote.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
100
+ if (match) return match[1];
101
+ } catch (_) {}
102
+ return "default";
103
+ }
104
+
105
+ let _cloudThreads = null;
106
+ let _cloudPresence = null;
107
+ let _cloudPresenceMap = null;
108
+
109
+ function buildPresenceMap(presence) {
110
+ const map = {};
111
+ for (const entry of presence) {
112
+ if (entry.user) map[entry.user] = { active_files: entry.active_files || [], last_seen: entry.last_seen };
113
+ }
114
+ return map;
115
+ }
116
+
117
+ async function initCloudCache() {
118
+ const { url, apiKey } = getCloudConfig();
119
+ if (!url) return;
120
+
121
+ const headers = { "X-Sticky-Project": getProjectName() };
122
+ if (apiKey) headers["X-Sticky-API-Key"] = apiKey;
123
+
124
+ function timedFetch(endpoint) {
125
+ const controller = new AbortController();
126
+ const t = setTimeout(() => controller.abort(), 5000);
127
+ return fetch(`${url}${endpoint}`, { headers, signal: controller.signal })
128
+ .finally(() => clearTimeout(t));
129
+ }
130
+
131
+ try {
132
+ const [threadsRes, presenceRes] = await Promise.all([
133
+ timedFetch("/threads"),
134
+ timedFetch("/presence"),
135
+ ]);
136
+ if (threadsRes.ok) {
137
+ const data = await threadsRes.json();
138
+ _cloudThreads = data.threads || [];
139
+ }
140
+ if (presenceRes.ok) {
141
+ const data = await presenceRes.json();
142
+ _cloudPresence = data.presence || [];
143
+ _cloudPresenceMap = buildPresenceMap(_cloudPresence);
144
+ }
145
+ } catch (_) {
146
+ process.stderr.write("[sticky-note] Cloud unreachable — using local data\n");
147
+ }
41
148
  }
42
149
 
43
150
  // ──────────────────────────────────────────────
@@ -69,6 +176,7 @@ function getConfig() {
69
176
  }
70
177
 
71
178
  function getThreads() {
179
+ if (_cloudThreads !== null) return _cloudThreads;
72
180
  return getMemory().threads || [];
73
181
  }
74
182
 
@@ -77,10 +185,11 @@ function getThreads() {
77
185
  * Returns { username: { active_files, last_seen } }
78
186
  */
79
187
  function getAllPresence() {
188
+ if (_cloudPresenceMap !== null) return _cloudPresenceMap;
189
+
80
190
  const presenceDir = path.join(stickyDir(), "presence");
81
191
  const result = {};
82
192
  if (!fs.existsSync(presenceDir)) return result;
83
-
84
193
  for (const file of fs.readdirSync(presenceDir)) {
85
194
  if (!file.endsWith(".json")) continue;
86
195
  const user = file.replace(".json", "");
@@ -156,6 +265,60 @@ function toolGetSessionContext(params) {
156
265
  return { thread };
157
266
  }
158
267
 
268
+ /**
269
+ * Return the full verbatim transcript(s) captured for a thread — one entry
270
+ * per contributing session (a resumed thread may have several). Secrets
271
+ * are redacted before storage (see redactSecrets in sticky-utils.js) unless
272
+ * a team has disabled redact_transcripts.
273
+ */
274
+ function toolGetFullTranscript(params) {
275
+ const { id } = params;
276
+ if (!id) {
277
+ return { error: "Missing required parameter: id" };
278
+ }
279
+
280
+ const threads = getThreads();
281
+ const thread = threads.find((t) => t.id === id || t.id.startsWith(id) || t.session_id === id);
282
+ if (!thread) {
283
+ return { error: `No thread found with id: ${id}` };
284
+ }
285
+
286
+ const filePath = getTranscriptPath(thread.id);
287
+ if (!fs.existsSync(filePath)) {
288
+ return {
289
+ thread_id: thread.id,
290
+ transcript_captured: !!thread.transcript_captured,
291
+ sessions: [],
292
+ note: "No transcript captured for this thread — it may predate transcript capture, or capture_transcripts is disabled for this team.",
293
+ };
294
+ }
295
+
296
+ const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/).filter((l) => l.trim());
297
+ const sessions = [];
298
+ for (const line of lines) {
299
+ let entry;
300
+ try {
301
+ entry = JSON.parse(line);
302
+ } catch {
303
+ continue;
304
+ }
305
+ sessions.push({
306
+ session_id: entry.session_id,
307
+ tool: entry.tool,
308
+ user: entry.user,
309
+ captured_at: entry.captured_at,
310
+ redacted: !!entry.redacted,
311
+ transcript: entry.transcript,
312
+ });
313
+ }
314
+
315
+ return {
316
+ thread_id: thread.id,
317
+ session_count: sessions.length,
318
+ sessions,
319
+ };
320
+ }
321
+
159
322
  function toolGetStuckThreads() {
160
323
  const threads = getThreads();
161
324
  const stuck = threads.filter((t) => t.status === "stuck");
@@ -515,6 +678,21 @@ const TOOLS = {
515
678
  },
516
679
  handler: toolGetSessionContext,
517
680
  },
681
+ get_full_transcript: {
682
+ description:
683
+ "Get the full verbatim transcript(s) captured for a thread by ID — one entry per contributing session. Secrets are redacted before storage unless a team disabled redact_transcripts. Returns an empty sessions array if no transcript was captured (e.g. capture_transcripts is off, or the thread predates this feature).",
684
+ inputSchema: {
685
+ type: "object",
686
+ properties: {
687
+ id: {
688
+ type: "string",
689
+ description: "Thread ID or session ID to look up",
690
+ },
691
+ },
692
+ required: ["id"],
693
+ },
694
+ handler: toolGetFullTranscript,
695
+ },
518
696
  get_stuck_threads: {
519
697
  description:
520
698
  "Get all threads with status 'stuck'. Returns full payloads including failed approaches and last notes. Call at session start to check for team blockers.",
@@ -634,7 +812,7 @@ const TOOLS = {
634
812
 
635
813
  const SERVER_INFO = {
636
814
  name: "sticky-note",
637
- version: "3.0.0",
815
+ version: "3.1.0",
638
816
  };
639
817
 
640
818
  const CAPABILITIES = {
@@ -746,43 +924,47 @@ function handleRequest(msg) {
746
924
  // stdio transport
747
925
  // ──────────────────────────────────────────────
748
926
 
749
- let buffer = "";
927
+ (async () => {
928
+ await initCloudCache();
750
929
 
751
- process.stdin.setEncoding("utf-8");
752
- process.stdin.on("data", (chunk) => {
753
- buffer += chunk;
930
+ let buffer = "";
754
931
 
755
- let newlineIdx;
756
- while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
757
- const line = buffer.slice(0, newlineIdx).trim();
758
- buffer = buffer.slice(newlineIdx + 1);
932
+ process.stdin.setEncoding("utf-8");
933
+ process.stdin.on("data", (chunk) => {
934
+ buffer += chunk;
759
935
 
760
- if (!line) continue;
936
+ let newlineIdx;
937
+ while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
938
+ const line = buffer.slice(0, newlineIdx).trim();
939
+ buffer = buffer.slice(newlineIdx + 1);
761
940
 
762
- let msg;
763
- try {
764
- msg = JSON.parse(line);
765
- } catch {
766
- const errResp = {
767
- jsonrpc: "2.0",
768
- id: null,
769
- error: { code: -32700, message: "Parse error" },
770
- };
771
- process.stdout.write(JSON.stringify(errResp) + "\n");
772
- continue;
773
- }
941
+ if (!line) continue;
774
942
 
775
- const response = handleRequest(msg);
776
- if (response) {
777
- process.stdout.write(JSON.stringify(response) + "\n");
943
+ let msg;
944
+ try {
945
+ msg = JSON.parse(line);
946
+ } catch {
947
+ const errResp = {
948
+ jsonrpc: "2.0",
949
+ id: null,
950
+ error: { code: -32700, message: "Parse error" },
951
+ };
952
+ process.stdout.write(JSON.stringify(errResp) + "\n");
953
+ continue;
954
+ }
955
+
956
+ const response = handleRequest(msg);
957
+ if (response) {
958
+ process.stdout.write(JSON.stringify(response) + "\n");
959
+ }
778
960
  }
779
- }
780
- });
961
+ });
781
962
 
782
- process.stdin.on("end", () => {
783
- process.exit(0);
784
- });
963
+ process.stdin.on("end", () => {
964
+ process.exit(0);
965
+ });
785
966
 
786
- process.on("uncaughtException", (err) => {
787
- process.stderr.write(`sticky-note MCP server error: ${err.message}\n`);
788
- });
967
+ process.on("uncaughtException", (err) => {
968
+ process.stderr.write(`sticky-note MCP server error: ${err.message}\n`);
969
+ });
970
+ })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sticky-note-cli",
3
- "version": "2.9.2",
3
+ "version": "3.1.0",
4
4
  "description": "Human-to-human handoff for AI coding assistants. Git-backed shared memory for Claude Code and Copilot CLI.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -33,6 +33,7 @@ cat .sticky-note/sticky-note.json
33
33
  | `resumed_by` | Most recent user to resume this thread (V2.5) |
34
34
  | `resumed_at` | ISO timestamp of most recent resume (V2.5) |
35
35
  | `resume_history` | Full resume chain: user, timestamp, session (V2.5) |
36
+ | `transcript_captured` | Whether a full verbatim session transcript exists for this thread (V3.1) |
36
37
 
37
38
  ### Status icons
38
39
 
@@ -107,6 +108,24 @@ approaches, prompts). Read and present this context to the user immediately —
107
108
  do NOT tell them to start a new session. The inject-context hook will also
108
109
  pick up the resumed thread on subsequent prompts.
109
110
 
111
+ ### Full transcript capture (V3.1)
112
+
113
+ By default, each session's complete verbatim transcript is captured — not
114
+ just the narrative summary — keyed by thread ID and stored alongside audit
115
+ logs and presence data. Secrets are redacted before anything is written
116
+ (best-effort pattern matching, not a guarantee).
117
+
118
+ - A thread's `transcript_captured` field is `true` once at least one
119
+ session's transcript has been persisted.
120
+ - Retrieve it with `npx sticky-note transcript <thread-id>` (add `--raw` for
121
+ the untouched JSONL) or the `get_full_transcript(id)` MCP tool.
122
+ - A team can turn this off with `"capture_transcripts": false` in
123
+ `sticky-note-config.json` — e.g. for a repo where sessions routinely
124
+ touch especially sensitive material.
125
+ - When a user asks "what did we actually discuss in that session" (as
126
+ opposed to "what was the summary"), prefer the transcript over
127
+ `narrative`/`prompts` — those are truncated summaries, not the full record.
128
+
110
129
  ### File attribution (V2.5)
111
130
 
112
131
  To see which threads are related to a specific file:
@@ -245,6 +264,10 @@ You have access to a `sticky-note` MCP server. Use it:
245
264
  4. **For audit history**: Call `get_audit_trail(file)` to understand who
246
265
  changed a file and when.
247
266
 
267
+ 5. **For the full conversation, not just the summary**: Call
268
+ `get_full_transcript(id)` to get the verbatim session transcript(s) for
269
+ a thread, when `narrative` or `prompts` aren't enough detail.
270
+
248
271
  These tools are the primary way sticky-note communicates with you.
249
272
  Hook-injected context supplements but does not replace MCP tool calls.
250
273
 
@@ -1,3 +1,6 @@
1
+ # Sticky Note - data moved to sticky-note/data branch (.git/sticky-note/ cache)
2
+ .sticky-note/
3
+
1
4
  # Sticky Note - local overrides
2
5
  .claude/settings.local.json
3
6
 
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * data-branch.js — Git plumbing for the sticky-note/data orphan branch.
5
+ *
6
+ * Reads/writes files via git plumbing (hash-object, update-index, write-tree,
7
+ * commit-tree, update-ref). No working-tree checkout is ever performed.
8
+ * All operations are synchronous; callers receive result objects on failure.
9
+ */
10
+
11
+ const fs = require("fs");
12
+ const os = require("os");
13
+ const path = require("path");
14
+ const crypto = require("crypto");
15
+ const { execFileSync } = require("child_process");
16
+
17
+ const DATA_BRANCH = "sticky-note/data";
18
+ const DATA_REF = "refs/heads/" + DATA_BRANCH;
19
+
20
+ const GIT_OPTS = { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] };
21
+
22
+ // ── Git helpers ───────────────────────────────────────────
23
+
24
+ function getDefaultRemote() {
25
+ try {
26
+ const out = execFileSync("git", ["remote"], {
27
+ encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
28
+ }).trim();
29
+ return out.split(/\r?\n/)[0] || null;
30
+ } catch (_) {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** Returns file content from a git ref as a string, or null if not found. */
36
+ function readFileFromBranch(ref, filePath) {
37
+ try {
38
+ return execFileSync("git", ["show", ref + ":" + filePath], GIT_OPTS);
39
+ } catch (_) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Commit { relativePath: content } to a branch using git plumbing.
46
+ * Does not touch the working tree or the main index. Returns the new commit SHA.
47
+ */
48
+ function commitFilesToBranch(branchName, fileMap) {
49
+ const branchRef = "refs/heads/" + branchName;
50
+ const tmpIndex = path.join(
51
+ os.tmpdir(),
52
+ "sticky-idx-" + process.pid + "-" + crypto.randomBytes(4).toString("hex")
53
+ );
54
+
55
+ try {
56
+ let parentSha = null;
57
+ try {
58
+ parentSha = execFileSync("git", ["rev-parse", "--verify", branchRef], {
59
+ encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
60
+ }).trim();
61
+ } catch (_) {
62
+ // Branch doesn't exist yet — first commit creates it as orphan
63
+ }
64
+
65
+ const indexEnv = { ...process.env, GIT_INDEX_FILE: tmpIndex };
66
+
67
+ if (parentSha) {
68
+ execFileSync("git", ["read-tree", parentSha], {
69
+ timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
70
+ env: indexEnv,
71
+ });
72
+ }
73
+
74
+ for (const [filePath, content] of Object.entries(fileMap)) {
75
+ const blobSha = execFileSync("git", ["hash-object", "-w", "--stdin"], {
76
+ input: content,
77
+ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
78
+ }).trim();
79
+
80
+ execFileSync(
81
+ "git",
82
+ ["update-index", "--add", "--cacheinfo", "100644," + blobSha + "," + filePath],
83
+ { timeout: 5000, stdio: ["pipe", "pipe", "pipe"], env: indexEnv }
84
+ );
85
+ }
86
+
87
+ const treeSha = execFileSync("git", ["write-tree"], {
88
+ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
89
+ env: indexEnv,
90
+ }).trim();
91
+
92
+ const commitArgs = ["commit-tree", treeSha, "-m", "chore(sticky-note): sync thread data"];
93
+ if (parentSha) {
94
+ commitArgs.push("-p", parentSha);
95
+ }
96
+
97
+ const commitSha = execFileSync("git", commitArgs, GIT_OPTS).trim();
98
+
99
+ execFileSync("git", ["update-ref", branchRef, commitSha], {
100
+ timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
101
+ });
102
+
103
+ return commitSha;
104
+ } finally {
105
+ try { fs.unlinkSync(tmpIndex); } catch (_) {}
106
+ }
107
+ }
108
+
109
+ // ── Fetch ─────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Fetch the remote data branch into a local remote-tracking ref.
113
+ * Returns { ok, sha, error, remoteRef }.
114
+ */
115
+ function fetchDataBranch(remote, branchName) {
116
+ branchName = branchName || DATA_BRANCH;
117
+ remote = remote || getDefaultRemote();
118
+ if (!remote) return { ok: false, sha: null, error: "no remote configured", remoteRef: null };
119
+
120
+ const remoteRef = "refs/remotes/" + remote + "/" + branchName;
121
+ try {
122
+ execFileSync(
123
+ "git",
124
+ ["fetch", remote, branchName + ":" + remoteRef],
125
+ { timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }
126
+ );
127
+ let sha = null;
128
+ try {
129
+ sha = execFileSync("git", ["rev-parse", "--verify", remoteRef], {
130
+ encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"],
131
+ }).trim();
132
+ } catch (_) {}
133
+ return { ok: true, sha, error: null, remoteRef };
134
+ } catch (err) {
135
+ return { ok: false, sha: null, error: err.message, remoteRef };
136
+ }
137
+ }
138
+
139
+ // ── Push with retry ────────────────────────────────────────
140
+
141
+ /**
142
+ * Push the local data branch to the remote, retrying on rejection.
143
+ * On non-fast-forward rejection, fetches and merges before retrying.
144
+ * Returns { ok, error }.
145
+ */
146
+ function pushDataBranch(remote, branchName, maxRetries, localMemPath, loadJsonFn, saveJsonFn) {
147
+ branchName = branchName || DATA_BRANCH;
148
+ remote = remote || getDefaultRemote();
149
+ maxRetries = maxRetries || 3;
150
+ if (!remote) return { ok: false, error: "no remote configured" };
151
+
152
+ const pushSpec = DATA_REF + ":" + DATA_REF;
153
+
154
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
155
+ try {
156
+ execFileSync("git", ["push", remote, pushSpec], {
157
+ timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
158
+ });
159
+ return { ok: true, error: null };
160
+ } catch (err) {
161
+ if (attempt === maxRetries) {
162
+ return { ok: false, error: err.message };
163
+ }
164
+
165
+ // Fetch, merge, and re-commit so the next push attempt advances the ref
166
+ try {
167
+ const fetchResult = fetchDataBranch(remote, branchName);
168
+ if (fetchResult.ok && fetchResult.remoteRef && localMemPath) {
169
+ const remoteContent = readFileFromBranch(fetchResult.remoteRef, "sticky-note.json");
170
+ if (remoteContent) {
171
+ mergeAndSaveFromRemote(localMemPath, remoteContent, loadJsonFn, saveJsonFn);
172
+ const mergedContent = fs.readFileSync(localMemPath, "utf-8");
173
+ commitFilesToBranch(branchName, { "sticky-note.json": mergedContent });
174
+ }
175
+ }
176
+ } catch (_) {}
177
+
178
+ // Brief exponential backoff (busy-wait — synchronous context)
179
+ const end = Date.now() + Math.pow(2, attempt) * 300;
180
+ while (Date.now() < end) {}
181
+ }
182
+ }
183
+ return { ok: false, error: "max retries exceeded" };
184
+ }
185
+
186
+ // ── Thread merge ──────────────────────────────────────────
187
+
188
+ function _mostRecentTimestamp(thread) {
189
+ return thread.last_activity_at || thread.updated_at || thread.created_at || "";
190
+ }
191
+
192
+ /**
193
+ * Merge two thread arrays, preferring the more-recently-active copy
194
+ * when both contain the same thread ID.
195
+ */
196
+ function mergeThreadArrays(localThreads, remoteThreads) {
197
+ const merged = new Map();
198
+
199
+ for (const t of (localThreads || [])) {
200
+ if (t && t.id) merged.set(t.id, t);
201
+ }
202
+
203
+ for (const t of (remoteThreads || [])) {
204
+ if (!t || !t.id) continue;
205
+ const existing = merged.get(t.id);
206
+ if (!existing || _mostRecentTimestamp(t) > _mostRecentTimestamp(existing)) {
207
+ merged.set(t.id, t);
208
+ }
209
+ }
210
+
211
+ return Array.from(merged.values());
212
+ }
213
+
214
+ const EMPTY_MEMORY = { version: "2", project: "", threads: [] };
215
+
216
+ /**
217
+ * Merge remote sticky-note.json content into the local memory file.
218
+ * Uses loadJsonFn/saveJsonFn when provided, otherwise reads/writes directly.
219
+ */
220
+ function mergeAndSaveFromRemote(localMemPath, remoteContent, loadJsonFn, saveJsonFn) {
221
+ let remoteMemory;
222
+ try {
223
+ remoteMemory = JSON.parse(remoteContent);
224
+ } catch (_) {
225
+ return; // corrupt remote — skip
226
+ }
227
+
228
+ const localMemory = loadJsonFn
229
+ ? loadJsonFn(localMemPath, { ...EMPTY_MEMORY })
230
+ : { ...EMPTY_MEMORY };
231
+
232
+ const localThreads = Array.isArray(localMemory.threads) ? localMemory.threads.filter(Boolean) : [];
233
+ const remoteThreads = Array.isArray(remoteMemory.threads) ? remoteMemory.threads.filter(Boolean) : [];
234
+ localMemory.threads = mergeThreadArrays(localThreads, remoteThreads);
235
+
236
+ if (saveJsonFn) {
237
+ saveJsonFn(localMemPath, localMemory);
238
+ } else {
239
+ fs.mkdirSync(path.dirname(localMemPath), { recursive: true });
240
+ fs.writeFileSync(localMemPath, JSON.stringify(localMemory, null, 2) + "\n", "utf-8");
241
+ }
242
+ }
243
+
244
+ // ── Exports ───────────────────────────────────────────────
245
+
246
+ module.exports = {
247
+ DATA_BRANCH,
248
+ DATA_REF,
249
+ getDefaultRemote,
250
+ readFileFromBranch,
251
+ commitFilesToBranch,
252
+ fetchDataBranch,
253
+ pushDataBranch,
254
+ mergeThreadArrays,
255
+ mergeAndSaveFromRemote,
256
+ };