sticky-note-cli 2.5.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.
@@ -0,0 +1,525 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * inject-context.js — Relevance-Scored Thread Injection (V2)
5
+ *
6
+ * Hook: UserPromptSubmit (Claude Code) / userPromptSubmitted (Copilot CLI)
7
+ * Scores threads by file overlap, branch, recency, stuck status, same dev.
8
+ * Injects top 3-5 most relevant threads under a configurable token budget.
9
+ */
10
+
11
+ const { execSync } = require("child_process");
12
+
13
+ // ── Output helpers ────────────────────────────────────────
14
+
15
+ function _isCopilotCli() {
16
+ return process.argv.includes("--copilot-cli") || !!process.env.COPILOT_CLI;
17
+ }
18
+
19
+ function _emit(text) {
20
+ if (text === undefined) text = "";
21
+ if (_isCopilotCli()) {
22
+ process.stdout.write(JSON.stringify({ additionalContext: text }) + "\n");
23
+ } else {
24
+ process.stdout.write(JSON.stringify({ output: text }) + "\n");
25
+ }
26
+ }
27
+
28
+ function _safeExit() {
29
+ try {
30
+ _emit("");
31
+ } catch (_) {
32
+ process.stdout.write('{"output": ""}\n');
33
+ }
34
+ process.exit(0);
35
+ }
36
+
37
+ // ── Import sticky-utils ──────────────────────────────────
38
+
39
+ let utils;
40
+ try {
41
+ utils = require("./sticky-utils.js");
42
+ } catch (_) {
43
+ _safeExit();
44
+ }
45
+
46
+ let gitNotes;
47
+ try {
48
+ gitNotes = require("./sticky-git-notes.js");
49
+ } catch (_) {
50
+ gitNotes = null;
51
+ }
52
+ const {
53
+ getMemoryPath, loadJson, saveJson, getUser, getBranch,
54
+ getResumeThreadId, findThreadById, getSessionId,
55
+ appendAuditLine, detectTool, getConfigPath,
56
+ isThreadInjected, markThreadInjected, normalizeSep,
57
+ } = utils;
58
+
59
+ // ── Git helpers ───────────────────────────────────────────
60
+
61
+ function getRecentlyModifiedFiles() {
62
+ const files = new Set();
63
+ // Try HEAD~5 first; fall back to HEAD~1 for shallow repos
64
+ const diffTargets = ["HEAD~5", "HEAD~1"];
65
+ for (const target of diffTargets) {
66
+ try {
67
+ const result = execSync(`git diff --name-only ${target}`, {
68
+ encoding: "utf-8",
69
+ timeout: 5000,
70
+ stdio: ["pipe", "pipe", "pipe"],
71
+ });
72
+ for (const f of result.trim().split(/\r?\n/)) {
73
+ const trimmed = f.trim();
74
+ if (trimmed) files.add(trimmed);
75
+ }
76
+ break;
77
+ } catch (_) {
78
+ // target doesn't exist, try next
79
+ }
80
+ }
81
+ try {
82
+ const result = execSync("git diff --name-only", {
83
+ encoding: "utf-8",
84
+ timeout: 5000,
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ });
87
+ for (const f of result.trim().split(/\r?\n/)) {
88
+ const trimmed = f.trim();
89
+ if (trimmed) files.add(trimmed);
90
+ }
91
+ } catch (_) {
92
+ // ignore
93
+ }
94
+ return files;
95
+ }
96
+
97
+ // ── Keyword extraction ────────────────────────────────────
98
+
99
+ function extractKeywords(prompt) {
100
+ const keywords = new Set();
101
+ const words = prompt.toLowerCase().replace(/[/\\.]/g, " ").split(/\s+/);
102
+ for (const word of words) {
103
+ const cleaned = word.replace(/[()[\]{}"'`,;:]/g, "");
104
+ if (cleaned.length >= 2) {
105
+ keywords.add(cleaned);
106
+ }
107
+ }
108
+ for (let token of prompt.split(/\s+/)) {
109
+ token = token.replace(/^[()[\]{}"'`,;:]+/, "").replace(/[()[\]{}"'`,;:]+$/, "");
110
+ if (token.includes("/") || token.includes("\\") || token.includes(".")) {
111
+ keywords.add(token.toLowerCase());
112
+ const parts = normalizeSep(token).split("/");
113
+ for (const part of parts) {
114
+ if (part.length >= 2) {
115
+ keywords.add(part.toLowerCase());
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return keywords;
121
+ }
122
+
123
+ // ── Scoring ───────────────────────────────────────────────
124
+
125
+ function scoreThread(thread, recentlyModified, currentBranch, currentUser, promptKeywords) {
126
+ if (thread.status === "expired" || thread.status === "stale") {
127
+ return -1;
128
+ }
129
+
130
+ let score = 0.0;
131
+ const now = new Date();
132
+
133
+ // File overlap (weight 3)
134
+ const threadFiles = new Set(thread.files_touched || []);
135
+ let overlapCount = 0;
136
+ for (const f of threadFiles) {
137
+ if (recentlyModified.has(f)) overlapCount++;
138
+ }
139
+ if (overlapCount > 0) {
140
+ score += 3 * Math.min(overlapCount, 5);
141
+ }
142
+
143
+ // Prompt keyword match against thread files
144
+ for (const filePath of threadFiles) {
145
+ const pathLower = filePath.toLowerCase();
146
+ const pathParts = new Set(normalizeSep(pathLower).replace(/\./g, "/").split("/"));
147
+ for (const kw of promptKeywords) {
148
+ if (pathLower.includes(kw) || pathParts.has(kw)) {
149
+ score += 1;
150
+ break;
151
+ }
152
+ }
153
+ }
154
+
155
+ // Branch match (weight 2)
156
+ if (currentBranch && thread.branch === currentBranch) {
157
+ score += 2;
158
+ }
159
+
160
+ // Recency (weight 2) — decay over days
161
+ const tsField = thread.last_activity_at || thread.updated_at || thread.created_at || "";
162
+ if (tsField) {
163
+ try {
164
+ const ts = new Date(tsField.replace("Z", "+00:00"));
165
+ if (!isNaN(ts.getTime())) {
166
+ const daysAgo = Math.max((now - ts) / 86400000, 0);
167
+ const recency = Math.max(2 - daysAgo * 0.2, 0);
168
+ score += recency;
169
+ }
170
+ } catch (_) {
171
+ // ignore
172
+ }
173
+ }
174
+
175
+ // Stuck boost (+2)
176
+ if (thread.status === "stuck") {
177
+ score += 2;
178
+ }
179
+
180
+ // Same developer (weight 1)
181
+ const threadUser = thread.user || thread.author || "";
182
+ if (threadUser === currentUser) {
183
+ score += 1;
184
+ }
185
+
186
+ return score;
187
+ }
188
+
189
+ // ── Formatting ────────────────────────────────────────────
190
+
191
+ function _relativeTime(tsStr) {
192
+ try {
193
+ const ts = new Date(tsStr);
194
+ if (isNaN(ts.getTime())) return "";
195
+ const now = new Date();
196
+ const deltaMs = now - ts;
197
+ const hours = deltaMs / 3600000;
198
+ if (hours < 1) {
199
+ return Math.floor(deltaMs / 60000) + "min ago";
200
+ } else if (hours < 24) {
201
+ return Math.floor(hours) + "hrs ago";
202
+ } else {
203
+ return Math.floor(hours / 24) + "d ago";
204
+ }
205
+ } catch (_) {
206
+ return "";
207
+ }
208
+ }
209
+
210
+ function formatThread(thread, detailed) {
211
+ const user = thread.user || thread.author || "unknown";
212
+ const status = thread.status || "open";
213
+ const statusTag = status === "stuck" ? "[STUCK]" : status === "open" ? "[OPEN]" : "[CLOSED]";
214
+ const fileLimit = detailed ? 3 : 2;
215
+ const files = (thread.files_touched || []).slice(0, fileLimit).join(", ");
216
+
217
+ let line = statusTag + " " + files + " . " + user;
218
+
219
+ if (!detailed) {
220
+ const note = (thread.last_note || "").substring(0, 60);
221
+ if (note) line += " -- " + note;
222
+ return line;
223
+ }
224
+
225
+ const branch = thread.branch || "";
226
+ if (branch) line += " . " + branch;
227
+ const tsField = thread.last_activity_at || thread.updated_at || "";
228
+ if (tsField) line += " . " + _relativeTime(tsField);
229
+ line += "\n";
230
+
231
+ if (thread.narrative) {
232
+ line += thread.narrative.substring(0, 200) + "\n";
233
+ } else if (thread.last_note) {
234
+ line += thread.last_note.substring(0, 150) + "\n";
235
+ }
236
+
237
+ const prompts = thread.prompts || [];
238
+ if (prompts.length > 0) {
239
+ line += "Conversation (" + prompts.length + " prompt(s)):\n";
240
+ for (let i = 0; i < Math.min(prompts.length, 5); i++) {
241
+ line += " " + (i + 1) + ". " + prompts[i].substring(0, 120) + "\n";
242
+ }
243
+ if (prompts.length > 5) line += " ... and " + (prompts.length - 5) + " more\n";
244
+ }
245
+
246
+ const failed = thread.failed_approaches || [];
247
+ if (failed.length > 0) {
248
+ line += failed.length + " failed approach(es).";
249
+ line += ' Full context: ask get_session_context("' + (thread.id || "") + '")\n';
250
+ }
251
+
252
+ return line.trim();
253
+ }
254
+
255
+ // ── Main ──────────────────────────────────────────────────
256
+
257
+ function main() {
258
+ let hookInput = {};
259
+ try {
260
+ const raw = require("fs").readFileSync(0, "utf-8");
261
+ if (raw.trim()) {
262
+ hookInput = JSON.parse(raw);
263
+ }
264
+ } catch (_) {
265
+ hookInput = {};
266
+ }
267
+
268
+ const prompt = hookInput.prompt || hookInput.user_prompt || "";
269
+ if (!prompt) {
270
+ _emit("");
271
+ return;
272
+ }
273
+
274
+ const sessionId = getSessionId(hookInput);
275
+
276
+ function _auditInject(result, threadsScored, threadsInjected, topScores, error) {
277
+ threadsScored = threadsScored || 0;
278
+ threadsInjected = threadsInjected || 0;
279
+ try {
280
+ const entry = {
281
+ type: "inject_result",
282
+ user: getUser(),
283
+ ts: new Date().toISOString(),
284
+ session_id: sessionId,
285
+ prompt: prompt.substring(0, 120),
286
+ result: result,
287
+ threads_scored: threadsScored,
288
+ threads_injected: threadsInjected,
289
+ };
290
+ if (topScores) entry.top_scores = topScores;
291
+ if (error) entry.error = String(error).substring(0, 200);
292
+ appendAuditLine(entry);
293
+ } catch (_) {
294
+ // ignore
295
+ }
296
+ }
297
+
298
+ // Audit the user prompt
299
+ try {
300
+ appendAuditLine({
301
+ type: "user_prompt",
302
+ user: getUser(),
303
+ ts: new Date().toISOString(),
304
+ session_id: sessionId,
305
+ prompt: prompt.substring(0, 500),
306
+ });
307
+ } catch (_) {
308
+ // ignore
309
+ }
310
+
311
+ // Auto-checkpoint: tag subsequent edits with what the user asked for
312
+ if (gitNotes) {
313
+ try {
314
+ gitNotes.saveCheckpoint({
315
+ topic: prompt.substring(0, 200),
316
+ user: getUser(),
317
+ ts: new Date().toISOString(),
318
+ session_id: sessionId,
319
+ auto: true,
320
+ });
321
+ } catch (_) {
322
+ // ignore — checkpoint is best-effort
323
+ }
324
+ }
325
+
326
+ const memory = loadJson(getMemoryPath(), { version: "2", threads: [] });
327
+ const threads = memory.threads || [];
328
+
329
+ const live = threads.filter(
330
+ (t) => t.status === "open" || t.status === "stuck" || t.status === "closed"
331
+ );
332
+ if (live.length === 0) {
333
+ _auditInject("no_live_threads");
334
+ _emit("");
335
+ return;
336
+ }
337
+
338
+ const keywords = extractKeywords(prompt);
339
+ const currentBranch = getBranch();
340
+ const currentUser = getUser();
341
+ const recentlyModified = getRecentlyModifiedFiles();
342
+
343
+ const resumeThreadId = getResumeThreadId();
344
+ let memoryDirty = false;
345
+
346
+ if (resumeThreadId) {
347
+ const resumed = findThreadById(threads, resumeThreadId);
348
+ if (resumed && resumed.status !== "open") {
349
+ resumed.status = "open";
350
+ resumed.last_activity_at = new Date().toISOString();
351
+ const related = resumed.related_session_ids || [];
352
+ if (!related.includes(sessionId)) {
353
+ related.push(sessionId);
354
+ }
355
+ resumed.related_session_ids = related;
356
+ const chain = resumed.resume_chain || [];
357
+ const alreadyInChain = chain.some((e) => e.session_id === sessionId);
358
+ if (!alreadyInChain) {
359
+ const aiTool = detectTool(hookInput);
360
+ const prevSession =
361
+ chain.length > 0
362
+ ? chain[chain.length - 1].session_id
363
+ : resumed.session_id || "";
364
+ chain.push({
365
+ session_id: sessionId,
366
+ tool: aiTool,
367
+ started_at: new Date().toISOString(),
368
+ ended_at: null,
369
+ resumed_from: prevSession,
370
+ });
371
+ }
372
+ resumed.resume_chain = chain;
373
+ memoryDirty = true;
374
+ }
375
+ }
376
+
377
+ // Score threads
378
+ const scored = [];
379
+ for (const t of live) {
380
+ // V2.5: Skip threads already injected this session by PreToolUse or session-start
381
+ if (isThreadInjected(t.id)) continue;
382
+
383
+ let s = scoreThread(t, recentlyModified, currentBranch, currentUser, keywords);
384
+ if (resumeThreadId && t.id === resumeThreadId) {
385
+ s = Math.max(s, 0) + 10;
386
+ }
387
+ if (s > 0) {
388
+ scored.push([s, t]);
389
+ }
390
+ }
391
+
392
+ if (memoryDirty) {
393
+ try {
394
+ saveJson(getMemoryPath(), memory);
395
+ } catch (_) {
396
+ // ignore
397
+ }
398
+ }
399
+
400
+ scored.sort((a, b) => b[0] - a[0]);
401
+
402
+ // Debug / scoring block
403
+ const debugLines = [
404
+ "\n---\n[STICKY-NOTE] [scoring] prompt: " + prompt.substring(0, 80),
405
+ ];
406
+ debugLines.push(
407
+ " keywords: " +
408
+ Array.from(keywords)
409
+ .sort()
410
+ .slice(0, 15)
411
+ .join(", ")
412
+ );
413
+ debugLines.push(" branch: " + currentBranch + " user: " + currentUser);
414
+ debugLines.push(
415
+ " recently_modified: " +
416
+ Array.from(recentlyModified)
417
+ .sort()
418
+ .slice(0, 10)
419
+ .join(", ")
420
+ );
421
+ debugLines.push(
422
+ " live threads: " + live.length + " scored>0: " + scored.length
423
+ );
424
+ for (const [s, t] of scored.slice(0, 10)) {
425
+ const tid = (t.id || "").substring(0, 8);
426
+ const st = t.status || "?";
427
+ const files = (t.files_touched || []).slice(0, 3);
428
+ const tool = t.tool || "?";
429
+ debugLines.push(
430
+ " " +
431
+ s.toFixed(1).padStart(5) +
432
+ " " +
433
+ tid +
434
+ " [" +
435
+ st +
436
+ "] " +
437
+ tool +
438
+ " files=" +
439
+ JSON.stringify(files)
440
+ );
441
+ }
442
+ const scoringBlock = debugLines.join("\n");
443
+
444
+ if (scored.length === 0) {
445
+ _auditInject("no_scored_threads", 0);
446
+ _emit("");
447
+ return;
448
+ }
449
+
450
+ const config = loadJson(getConfigPath(), {});
451
+ const MAX_TOKENS = config.inject_token_budget || 1000;
452
+ const _estimateTokens = (str) => Math.floor(str.length / 4);
453
+
454
+ // Reserve budget for the scoring debug block and header overhead
455
+ const scoringTokens = _estimateTokens(scoringBlock);
456
+ const HEADER_RESERVE = 15; // "[STICKY NOTE -- N relevant thread(s)]"
457
+ let tokenCount = HEADER_RESERVE + scoringTokens;
458
+
459
+ const outputLines = [];
460
+ let threadsShown = 0;
461
+
462
+ for (let i = 0; i < Math.min(scored.length, 5); i++) {
463
+ const [score, thread] = scored[i];
464
+ const block =
465
+ i === 0 ? formatThread(thread, true) : formatThread(thread, false);
466
+ const blockTokens = _estimateTokens(block);
467
+ if (tokenCount + blockTokens > MAX_TOKENS) {
468
+ const remaining = scored.length - i;
469
+ if (remaining > 0) {
470
+ const overflowMsg = "... and " + remaining + " more relevant threads";
471
+ tokenCount += _estimateTokens(overflowMsg);
472
+ outputLines.push(overflowMsg);
473
+ }
474
+ break;
475
+ }
476
+ tokenCount += blockTokens;
477
+ outputLines.push(block);
478
+ threadsShown++;
479
+
480
+ // V2.5: Mark as injected so PreToolUse won't re-inject
481
+ try { markThreadInjected(thread.id, sessionId); } catch (_) {}
482
+ }
483
+
484
+ const header =
485
+ "[STICKY-NOTE] " +
486
+ threadsShown +
487
+ " relevant thread" +
488
+ (threadsShown !== 1 ? "s" : "") +
489
+ " injected\n";
490
+ outputLines.unshift(header);
491
+
492
+ let output = outputLines.join("\n").trim();
493
+ output += scoringBlock;
494
+
495
+ _auditInject(
496
+ "injected",
497
+ scored.length,
498
+ threadsShown,
499
+ scored.slice(0, 5).map(([s, t]) => ({
500
+ id: (t.id || "").substring(0, 8),
501
+ score: Math.round(s * 10) / 10,
502
+ }))
503
+ );
504
+ _emit(output);
505
+ }
506
+
507
+ // ── Entry point ───────────────────────────────────────────
508
+
509
+ if (require.main === module) {
510
+ try {
511
+ main();
512
+ } catch (exc) {
513
+ try {
514
+ appendAuditLine({
515
+ type: "inject_result",
516
+ ts: new Date().toISOString(),
517
+ result: "error",
518
+ error: String(exc).substring(0, 200),
519
+ });
520
+ } catch (_) {
521
+ // ignore
522
+ }
523
+ _safeExit();
524
+ }
525
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * on-error.js — Stuck Thread (V2)
5
+ *
6
+ * Hook: errorOccurred (Copilot CLI) / PostToolUseFailure (Claude Code)
7
+ * Writes a thread with status="stuck" and captures the error message.
8
+ * Appends JSONL audit line.
9
+ */
10
+
11
+ const crypto = require("crypto");
12
+
13
+ function _safeExit() {
14
+ try {
15
+ process.stdout.write(JSON.stringify({ output: "" }) + "\n");
16
+ } catch (_) {
17
+ process.stdout.write('{"output":""}\n');
18
+ }
19
+ process.exit(0);
20
+ }
21
+
22
+ let getMemoryPath, loadJson, saveJson, appendAuditLine, getUser, detectTool, getSessionId;
23
+ try {
24
+ ({
25
+ getMemoryPath,
26
+ loadJson,
27
+ saveJson,
28
+ appendAuditLine,
29
+ getUser,
30
+ detectTool,
31
+ getSessionId,
32
+ } = require("./sticky-utils.js"));
33
+ } catch (_) {
34
+ _safeExit();
35
+ }
36
+
37
+ function main() {
38
+ let hookInput = {};
39
+ try {
40
+ if (!process.stdin.isTTY) {
41
+ const raw = require("fs").readFileSync(0, "utf-8").trim();
42
+ if (raw) {
43
+ hookInput = JSON.parse(raw);
44
+ }
45
+ }
46
+ } catch (_) {
47
+ hookInput = {};
48
+ }
49
+
50
+ const sessionId = getSessionId(hookInput);
51
+ let toolName = hookInput.tool_name || process.env.TOOL_NAME || "unknown";
52
+ if (toolName === "unknown") {
53
+ toolName = detectTool(hookInput);
54
+ }
55
+ const errorMsg = (hookInput.error || hookInput.message || "Unknown error").substring(0, 200);
56
+ const user = getUser();
57
+ const now = new Date().toISOString();
58
+
59
+ const memoryPath = getMemoryPath();
60
+ const memory = loadJson(memoryPath, { version: "2", project: "", threads: [] });
61
+
62
+ if (!Array.isArray(memory.threads)) {
63
+ memory.threads = [];
64
+ }
65
+ const threads = memory.threads;
66
+
67
+ let existing = null;
68
+ for (const thread of threads) {
69
+ if (thread.session_id === sessionId) {
70
+ existing = thread;
71
+ break;
72
+ }
73
+ }
74
+
75
+ if (existing) {
76
+ existing.status = "stuck";
77
+ existing.last_note = errorMsg;
78
+ existing.last_activity_at = now;
79
+ if (!Array.isArray(existing.failed_approaches)) {
80
+ existing.failed_approaches = [];
81
+ }
82
+ existing.failed_approaches.push({
83
+ description: errorMsg.substring(0, 150),
84
+ error: errorMsg.substring(0, 100),
85
+ });
86
+ // Cap at 5 entries
87
+ if (existing.failed_approaches.length > 5) {
88
+ existing.failed_approaches = existing.failed_approaches.slice(-5);
89
+ }
90
+ } else {
91
+ const thread = {
92
+ id: crypto.randomUUID(),
93
+ user: user,
94
+ project: memory.project || "",
95
+ status: "stuck",
96
+ branch: "",
97
+ created_at: now,
98
+ closed_at: null,
99
+ last_activity_at: now,
100
+ files_touched: [],
101
+ last_note: errorMsg,
102
+ narrative: "",
103
+ failed_approaches: [{ description: errorMsg.substring(0, 150), error: errorMsg.substring(0, 100) }],
104
+ handoff_summary: "",
105
+ related_session_ids: [],
106
+ tool: toolName,
107
+ session_id: sessionId,
108
+ };
109
+ threads.push(thread);
110
+ }
111
+
112
+ appendAuditLine({
113
+ type: "error",
114
+ user: user,
115
+ ts: now,
116
+ session_id: sessionId,
117
+ error: errorMsg,
118
+ tool: toolName,
119
+ });
120
+
121
+ saveJson(memoryPath, memory);
122
+ const statusMsg = `[STICKY-NOTE] Marked thread as STUCK - ${errorMsg.substring(0, 80)}`;
123
+ try {
124
+ process.stdout.write(JSON.stringify({ output: statusMsg }) + "\n");
125
+ } catch (_) {
126
+ process.stdout.write('{"output":""}\n');
127
+ }
128
+ process.exit(0);
129
+ }
130
+
131
+ try {
132
+ main();
133
+ } catch (_) {
134
+ _safeExit();
135
+ }