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/README.md +94 -10
- package/bin/cli.js +1153 -174
- package/bin/mcp-server.js +218 -36
- package/package.json +1 -1
- package/templates/CLAUDE.md +23 -0
- package/templates/gitignore-additions.txt +3 -0
- package/templates/hooks/data-branch.js +256 -0
- package/templates/hooks/inject-context.js +26 -12
- package/templates/hooks/on-error.js +22 -9
- package/templates/hooks/on-stop.js +12 -7
- package/templates/hooks/pre-tool-use.js +49 -52
- package/templates/hooks/session-end.js +190 -21
- package/templates/hooks/session-start.js +81 -9
- package/templates/hooks/sticky-codex.ps1 +20 -4
- package/templates/hooks/sticky-codex.sh +14 -5
- package/templates/hooks/sticky-utils.js +335 -29
- package/templates/hooks/track-work.js +27 -13
- package/templates/hooks.json +47 -47
- package/templates/settings.json +81 -81
- package/templates/sticky-note-config.json +2 -0
- package/templates/sticky-note-install.yml +80 -0
- package/templates/sticky-note.json +5 -5
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
927
|
+
(async () => {
|
|
928
|
+
await initCloudCache();
|
|
750
929
|
|
|
751
|
-
|
|
752
|
-
process.stdin.on("data", (chunk) => {
|
|
753
|
-
buffer += chunk;
|
|
930
|
+
let buffer = "";
|
|
754
931
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
buffer = buffer.slice(newlineIdx + 1);
|
|
932
|
+
process.stdin.setEncoding("utf-8");
|
|
933
|
+
process.stdin.on("data", (chunk) => {
|
|
934
|
+
buffer += chunk;
|
|
759
935
|
|
|
760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
-
|
|
784
|
-
});
|
|
963
|
+
process.stdin.on("end", () => {
|
|
964
|
+
process.exit(0);
|
|
965
|
+
});
|
|
785
966
|
|
|
786
|
-
process.on("uncaughtException", (err) => {
|
|
787
|
-
|
|
788
|
-
});
|
|
967
|
+
process.on("uncaughtException", (err) => {
|
|
968
|
+
process.stderr.write(`sticky-note MCP server error: ${err.message}\n`);
|
|
969
|
+
});
|
|
970
|
+
})();
|
package/package.json
CHANGED
package/templates/CLAUDE.md
CHANGED
|
@@ -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
|
|
|
@@ -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
|
+
};
|