stickyinc 0.5.2
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/LICENSE +21 -0
- package/README.md +377 -0
- package/dist/bundle/stickyinc-mcp.mjs +22072 -0
- package/dist/bundle/stickyinc-watch.mjs +1013 -0
- package/package.json +53 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/watcher.ts
|
|
4
|
+
import {
|
|
5
|
+
closeSync,
|
|
6
|
+
existsSync as existsSync3,
|
|
7
|
+
openSync,
|
|
8
|
+
readFileSync as readFileSync3,
|
|
9
|
+
readSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
statSync as statSync2,
|
|
12
|
+
writeFileSync
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { homedir as homedir4 } from "node:os";
|
|
15
|
+
import { join as join5 } from "node:path";
|
|
16
|
+
|
|
17
|
+
// src/db.ts
|
|
18
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
19
|
+
import { mkdirSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join, resolve } from "node:path";
|
|
22
|
+
import { DatabaseSync } from "node:sqlite";
|
|
23
|
+
function dbPath() {
|
|
24
|
+
const override = process.env.STICKYINC_DB;
|
|
25
|
+
if (!override) return join(homedir(), ".stickyinc", "tasks.db");
|
|
26
|
+
return resolve(override.replace(/^~(?=$|[\\/])/, homedir()));
|
|
27
|
+
}
|
|
28
|
+
var DB_PATH = dbPath();
|
|
29
|
+
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
30
|
+
var db = new DatabaseSync(DB_PATH);
|
|
31
|
+
db.exec(`PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000;`);
|
|
32
|
+
function inTransaction(fn) {
|
|
33
|
+
db.exec("BEGIN IMMEDIATE");
|
|
34
|
+
try {
|
|
35
|
+
const result = fn();
|
|
36
|
+
db.exec("COMMIT");
|
|
37
|
+
return result;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
db.exec("ROLLBACK");
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
db.exec(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
45
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
46
|
+
uuid TEXT,
|
|
47
|
+
text TEXT NOT NULL,
|
|
48
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
49
|
+
completed_at TEXT,
|
|
50
|
+
due_at TEXT,
|
|
51
|
+
source TEXT NOT NULL DEFAULT 'claude',
|
|
52
|
+
fingerprint TEXT
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(completed_at);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_at);
|
|
56
|
+
|
|
57
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
58
|
+
key TEXT PRIMARY KEY,
|
|
59
|
+
value TEXT NOT NULL
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
CREATE TABLE IF NOT EXISTS task_events (
|
|
63
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
64
|
+
event_uuid TEXT NOT NULL UNIQUE,
|
|
65
|
+
task_uuid TEXT NOT NULL,
|
|
66
|
+
op TEXT NOT NULL CHECK (op IN ('create','complete','uncomplete','edit','delete')),
|
|
67
|
+
payload TEXT,
|
|
68
|
+
device_id TEXT NOT NULL,
|
|
69
|
+
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
|
70
|
+
lamport INTEGER NOT NULL DEFAULT 0
|
|
71
|
+
);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_uuid);
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_task_events_lamport ON task_events(device_id, lamport);
|
|
74
|
+
`);
|
|
75
|
+
{
|
|
76
|
+
const cols = db.prepare(`PRAGMA table_info(tasks)`).all();
|
|
77
|
+
if (!cols.some((c) => c.name === "fingerprint")) {
|
|
78
|
+
db.exec(`ALTER TABLE tasks ADD COLUMN fingerprint TEXT`);
|
|
79
|
+
}
|
|
80
|
+
if (!cols.some((c) => c.name === "uuid")) {
|
|
81
|
+
db.exec(`ALTER TABLE tasks ADD COLUMN uuid TEXT`);
|
|
82
|
+
}
|
|
83
|
+
const pending = db.prepare(`SELECT id FROM tasks WHERE uuid IS NULL`).all();
|
|
84
|
+
if (pending.length > 0) {
|
|
85
|
+
const setUuid = db.prepare(`UPDATE tasks SET uuid = ? WHERE id = ?`);
|
|
86
|
+
inTransaction(() => {
|
|
87
|
+
for (const row of pending) setUuid.run(randomUUID(), row.id);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
db.exec(`
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_fingerprint ON tasks(fingerprint);
|
|
93
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_uuid ON tasks(uuid);
|
|
94
|
+
`);
|
|
95
|
+
function ensureDeviceId() {
|
|
96
|
+
const row = db.prepare(`SELECT value FROM meta WHERE key = 'device_id'`).get();
|
|
97
|
+
if (row) return row.value;
|
|
98
|
+
const id = randomUUID();
|
|
99
|
+
db.prepare(
|
|
100
|
+
`INSERT OR IGNORE INTO meta (key, value) VALUES ('device_id', ?)`
|
|
101
|
+
).run(id);
|
|
102
|
+
const final = db.prepare(`SELECT value FROM meta WHERE key = 'device_id'`).get();
|
|
103
|
+
return final.value;
|
|
104
|
+
}
|
|
105
|
+
var DEVICE_ID = ensureDeviceId();
|
|
106
|
+
function nextLamport() {
|
|
107
|
+
const row = db.prepare(`SELECT MAX(lamport) as m FROM task_events WHERE device_id = ?`).get(DEVICE_ID);
|
|
108
|
+
return (row.m ?? 0) + 1;
|
|
109
|
+
}
|
|
110
|
+
var insertEventStmt = db.prepare(
|
|
111
|
+
`INSERT INTO task_events (event_uuid, task_uuid, op, payload, device_id, lamport)
|
|
112
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
113
|
+
);
|
|
114
|
+
function recordEvent(op, taskUuid, payload) {
|
|
115
|
+
insertEventStmt.run(
|
|
116
|
+
randomUUID(),
|
|
117
|
+
taskUuid,
|
|
118
|
+
op,
|
|
119
|
+
payload === null ? null : JSON.stringify(payload),
|
|
120
|
+
DEVICE_ID,
|
|
121
|
+
nextLamport()
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
var insertTaskStmt = db.prepare(
|
|
125
|
+
`INSERT INTO tasks (uuid, text, due_at, source, fingerprint) VALUES (?, ?, ?, ?, ?) RETURNING *`
|
|
126
|
+
);
|
|
127
|
+
var findOpenByFingerprintStmt = db.prepare(
|
|
128
|
+
`SELECT * FROM tasks WHERE fingerprint = ? AND completed_at IS NULL LIMIT 1`
|
|
129
|
+
);
|
|
130
|
+
var countDoneTodayStmt = db.prepare(
|
|
131
|
+
`SELECT COUNT(*) as n FROM tasks WHERE completed_at IS NOT NULL
|
|
132
|
+
AND date(completed_at, 'localtime') = date('now', 'localtime')`
|
|
133
|
+
);
|
|
134
|
+
var selectOpenStmt = db.prepare(
|
|
135
|
+
`SELECT * FROM tasks WHERE completed_at IS NULL ORDER BY
|
|
136
|
+
CASE WHEN due_at IS NULL THEN 1 ELSE 0 END, due_at ASC, created_at ASC`
|
|
137
|
+
);
|
|
138
|
+
var selectAllStmt = db.prepare(
|
|
139
|
+
`SELECT * FROM tasks ORDER BY created_at DESC LIMIT ?`
|
|
140
|
+
);
|
|
141
|
+
var selectRecentDoneStmt = db.prepare(
|
|
142
|
+
`SELECT * FROM tasks
|
|
143
|
+
WHERE completed_at IS NOT NULL
|
|
144
|
+
AND completed_at >= datetime('now', ?)
|
|
145
|
+
ORDER BY completed_at DESC`
|
|
146
|
+
);
|
|
147
|
+
var selectArchivedStmt = db.prepare(
|
|
148
|
+
`SELECT * FROM tasks
|
|
149
|
+
WHERE completed_at IS NOT NULL
|
|
150
|
+
AND completed_at < datetime('now', ?)
|
|
151
|
+
ORDER BY completed_at DESC
|
|
152
|
+
LIMIT ?`
|
|
153
|
+
);
|
|
154
|
+
var completeTaskFindStmt = db.prepare(
|
|
155
|
+
`SELECT uuid, completed_at FROM tasks WHERE id = ?`
|
|
156
|
+
);
|
|
157
|
+
var completeTaskUpdateStmt = db.prepare(
|
|
158
|
+
`UPDATE tasks SET completed_at = datetime('now') WHERE id = ? AND completed_at IS NULL`
|
|
159
|
+
);
|
|
160
|
+
var getTaskStmt = db.prepare(`SELECT * FROM tasks WHERE id = ?`);
|
|
161
|
+
function fingerprint(text) {
|
|
162
|
+
return createHash("sha256").update(text.toLowerCase().replace(/\s+/g, " ").trim()).digest("hex").slice(0, 16);
|
|
163
|
+
}
|
|
164
|
+
function addTaskUnique(text, dueAt = null, source = "claude") {
|
|
165
|
+
const fp = fingerprint(text);
|
|
166
|
+
return inTransaction(() => {
|
|
167
|
+
const existing = findOpenByFingerprintStmt.get(fp);
|
|
168
|
+
if (existing) return { task: existing, inserted: false };
|
|
169
|
+
const taskUuid = randomUUID();
|
|
170
|
+
const task = insertTaskStmt.get(taskUuid, text, dueAt, source, fp);
|
|
171
|
+
recordEvent("create", taskUuid, { text, due_at: dueAt, source });
|
|
172
|
+
return { task, inserted: true };
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/dates.ts
|
|
177
|
+
function toStoredDue(input) {
|
|
178
|
+
const s = input.trim();
|
|
179
|
+
const d = /^\d{4}-\d{2}-\d{2}$/.test(s) ? /* @__PURE__ */ new Date(`${s}T09:00:00`) : new Date(s);
|
|
180
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
181
|
+
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
182
|
+
}
|
|
183
|
+
function timeContext(now = /* @__PURE__ */ new Date()) {
|
|
184
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
185
|
+
const weekday = (d) => d.toLocaleDateString("en-US", { weekday: "long" });
|
|
186
|
+
const ymd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
187
|
+
const hm = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
|
188
|
+
const week = Array.from({ length: 7 }, (_, i) => {
|
|
189
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + i + 1, 12);
|
|
190
|
+
return `${weekday(d)} ${ymd(d)}`;
|
|
191
|
+
});
|
|
192
|
+
return `Now (user's local time, ${tz}): ${weekday(now)} ${ymd(now)} ${hm}.
|
|
193
|
+
The next 7 days: ${week.join(", ")}.`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/providers/index.ts
|
|
197
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "node:fs";
|
|
198
|
+
import { homedir as homedir3 } from "node:os";
|
|
199
|
+
import { join as join4 } from "node:path";
|
|
200
|
+
|
|
201
|
+
// src/providers/types.ts
|
|
202
|
+
var CHAT_TIMEOUT_MS = Number(process.env.STICKYINC_LLM_TIMEOUT_MS) || 9e4;
|
|
203
|
+
|
|
204
|
+
// src/providers/anthropic.ts
|
|
205
|
+
var AnthropicProvider = class {
|
|
206
|
+
name = "anthropic";
|
|
207
|
+
model;
|
|
208
|
+
apiKey;
|
|
209
|
+
baseUrl;
|
|
210
|
+
constructor(cfg) {
|
|
211
|
+
this.apiKey = cfg.api_key;
|
|
212
|
+
this.model = cfg.model ?? "claude-haiku-4-5-20251001";
|
|
213
|
+
this.baseUrl = cfg.base_url ?? "https://api.anthropic.com";
|
|
214
|
+
}
|
|
215
|
+
async chat(opts2) {
|
|
216
|
+
const body = {
|
|
217
|
+
model: this.model,
|
|
218
|
+
max_tokens: opts2.max_tokens ?? 1024,
|
|
219
|
+
messages: opts2.messages
|
|
220
|
+
};
|
|
221
|
+
if (opts2.system) body.system = opts2.system;
|
|
222
|
+
if (typeof opts2.temperature === "number") body.temperature = opts2.temperature;
|
|
223
|
+
const res = await fetch(`${this.baseUrl}/v1/messages`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
headers: {
|
|
226
|
+
"x-api-key": this.apiKey,
|
|
227
|
+
"anthropic-version": "2023-06-01",
|
|
228
|
+
"content-type": "application/json"
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify(body),
|
|
231
|
+
signal: AbortSignal.timeout(CHAT_TIMEOUT_MS)
|
|
232
|
+
});
|
|
233
|
+
if (!res.ok) {
|
|
234
|
+
throw new Error(`Anthropic API error: ${res.status} ${await res.text()}`);
|
|
235
|
+
}
|
|
236
|
+
const data = await res.json();
|
|
237
|
+
const text = data.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
|
|
238
|
+
return {
|
|
239
|
+
content: text,
|
|
240
|
+
model: data.model,
|
|
241
|
+
provider: this.name,
|
|
242
|
+
usage: { input: data.usage.input_tokens, output: data.usage.output_tokens }
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// src/providers/openai.ts
|
|
248
|
+
var OpenAICompatProvider = class {
|
|
249
|
+
name;
|
|
250
|
+
model;
|
|
251
|
+
apiKey;
|
|
252
|
+
baseUrl;
|
|
253
|
+
extraHeaders;
|
|
254
|
+
constructor(cfg) {
|
|
255
|
+
this.apiKey = cfg.api_key;
|
|
256
|
+
this.model = cfg.model;
|
|
257
|
+
this.baseUrl = cfg.base_url.replace(/\/+$/, "");
|
|
258
|
+
this.name = cfg.provider_label ?? "openai";
|
|
259
|
+
this.extraHeaders = cfg.extra_headers ?? {};
|
|
260
|
+
}
|
|
261
|
+
async chat(opts2) {
|
|
262
|
+
const messages = [
|
|
263
|
+
...opts2.system ? [{ role: "system", content: opts2.system }] : [],
|
|
264
|
+
...opts2.messages
|
|
265
|
+
];
|
|
266
|
+
const body = {
|
|
267
|
+
model: this.model,
|
|
268
|
+
messages,
|
|
269
|
+
max_tokens: opts2.max_tokens ?? 1024
|
|
270
|
+
};
|
|
271
|
+
if (typeof opts2.temperature === "number") body.temperature = opts2.temperature;
|
|
272
|
+
if (opts2.response_format === "json") body.response_format = { type: "json_object" };
|
|
273
|
+
const res = await fetch(`${this.baseUrl}/chat/completions`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: {
|
|
276
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
277
|
+
"content-type": "application/json",
|
|
278
|
+
...this.extraHeaders
|
|
279
|
+
},
|
|
280
|
+
body: JSON.stringify(body),
|
|
281
|
+
signal: AbortSignal.timeout(CHAT_TIMEOUT_MS)
|
|
282
|
+
});
|
|
283
|
+
if (!res.ok) {
|
|
284
|
+
throw new Error(`${this.name} API error: ${res.status} ${await res.text()}`);
|
|
285
|
+
}
|
|
286
|
+
const data = await res.json();
|
|
287
|
+
return {
|
|
288
|
+
content: data.choices[0]?.message.content ?? "",
|
|
289
|
+
model: data.model ?? this.model,
|
|
290
|
+
provider: this.name,
|
|
291
|
+
usage: data.usage ? { input: data.usage.prompt_tokens, output: data.usage.completion_tokens } : void 0
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// src/providers/claude_code.ts
|
|
297
|
+
import { spawn } from "node:child_process";
|
|
298
|
+
|
|
299
|
+
// src/providers/which.ts
|
|
300
|
+
import { spawnSync } from "node:child_process";
|
|
301
|
+
import { existsSync } from "node:fs";
|
|
302
|
+
import { homedir as homedir2, userInfo } from "node:os";
|
|
303
|
+
import { delimiter, join as join2 } from "node:path";
|
|
304
|
+
var NAME_PATTERN = /^[a-z0-9_-]+$/;
|
|
305
|
+
function wellKnownDirs() {
|
|
306
|
+
const home = homedir2();
|
|
307
|
+
return [
|
|
308
|
+
join2(home, ".local", "bin"),
|
|
309
|
+
// Claude Code's native installer, codex, pipx, uv
|
|
310
|
+
"/opt/homebrew/bin",
|
|
311
|
+
"/usr/local/bin",
|
|
312
|
+
join2(home, ".npm-global", "bin"),
|
|
313
|
+
join2(home, ".volta", "bin"),
|
|
314
|
+
join2(home, ".bun", "bin")
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
function whichBinary(name) {
|
|
318
|
+
const paths = (process.env.PATH ?? "").split(delimiter);
|
|
319
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
320
|
+
for (const dir of paths) {
|
|
321
|
+
if (!dir) continue;
|
|
322
|
+
for (const ext of exts) {
|
|
323
|
+
const candidate = join2(dir, name + ext);
|
|
324
|
+
if (existsSync(candidate)) return candidate;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (process.platform === "win32") return null;
|
|
328
|
+
for (const dir of wellKnownDirs()) {
|
|
329
|
+
const candidate = join2(dir, name);
|
|
330
|
+
if (existsSync(candidate)) return candidate;
|
|
331
|
+
}
|
|
332
|
+
if (process.platform !== "darwin") return null;
|
|
333
|
+
if (!NAME_PATTERN.test(name)) return null;
|
|
334
|
+
try {
|
|
335
|
+
const shell = userInfo().shell || process.env.SHELL || "/bin/zsh";
|
|
336
|
+
const result = spawnSync(shell, ["-ilc", `command -v ${name}`], {
|
|
337
|
+
timeout: 3e3,
|
|
338
|
+
encoding: "utf8",
|
|
339
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
340
|
+
});
|
|
341
|
+
const lines = (result.stdout ?? "").split("\n").map((l) => l.trim());
|
|
342
|
+
for (const line of lines.reverse()) {
|
|
343
|
+
if (line.startsWith("/") && existsSync(line)) return line;
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/providers/claude_code.ts
|
|
351
|
+
function findClaudeBinary() {
|
|
352
|
+
return whichBinary("claude");
|
|
353
|
+
}
|
|
354
|
+
var ClaudeCodeProvider = class {
|
|
355
|
+
name = "claude-code";
|
|
356
|
+
model;
|
|
357
|
+
binary;
|
|
358
|
+
constructor(cfg) {
|
|
359
|
+
this.binary = cfg.binary;
|
|
360
|
+
this.model = cfg.model ?? "haiku";
|
|
361
|
+
}
|
|
362
|
+
async chat(opts2) {
|
|
363
|
+
const prompt = mergePrompt(opts2);
|
|
364
|
+
const args2 = [
|
|
365
|
+
"-p",
|
|
366
|
+
"--output-format",
|
|
367
|
+
"text",
|
|
368
|
+
"--no-session-persistence",
|
|
369
|
+
"--strict-mcp-config",
|
|
370
|
+
"--tools",
|
|
371
|
+
""
|
|
372
|
+
];
|
|
373
|
+
if (this.model) args2.push("--model", this.model);
|
|
374
|
+
return new Promise((resolve2, reject) => {
|
|
375
|
+
const child = spawn(this.binary, args2, {
|
|
376
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
377
|
+
});
|
|
378
|
+
const timer = setTimeout(() => {
|
|
379
|
+
child.kill();
|
|
380
|
+
reject(new Error(`claude -p gave no answer within ${CHAT_TIMEOUT_MS / 1e3}s`));
|
|
381
|
+
}, CHAT_TIMEOUT_MS);
|
|
382
|
+
let out = "";
|
|
383
|
+
let err = "";
|
|
384
|
+
child.stdout.setEncoding("utf8");
|
|
385
|
+
child.stderr.setEncoding("utf8");
|
|
386
|
+
child.stdout.on("data", (chunk) => out += chunk);
|
|
387
|
+
child.stderr.on("data", (chunk) => err += chunk);
|
|
388
|
+
child.on("error", (e) => {
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
reject(e);
|
|
391
|
+
});
|
|
392
|
+
child.on("close", (code) => {
|
|
393
|
+
clearTimeout(timer);
|
|
394
|
+
if (code !== 0) {
|
|
395
|
+
const stderrSnip = err.trim().slice(0, 400);
|
|
396
|
+
const stdoutSnip = out.trim().slice(0, 400);
|
|
397
|
+
reject(
|
|
398
|
+
new Error(
|
|
399
|
+
`claude -p exited ${code}: ${stderrSnip || stdoutSnip || "(no output)"}`
|
|
400
|
+
)
|
|
401
|
+
);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
resolve2({
|
|
405
|
+
content: out.trim(),
|
|
406
|
+
model: this.model,
|
|
407
|
+
provider: this.name
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
child.stdin.write(prompt);
|
|
411
|
+
child.stdin.end();
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
function mergePrompt(opts2) {
|
|
416
|
+
const parts = [];
|
|
417
|
+
if (opts2.system) parts.push(opts2.system);
|
|
418
|
+
for (const m of opts2.messages) {
|
|
419
|
+
if (m.role === "user") parts.push(m.content);
|
|
420
|
+
else parts.push(`[previous assistant reply]
|
|
421
|
+
${m.content}`);
|
|
422
|
+
}
|
|
423
|
+
return parts.join("\n\n");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/providers/codex.ts
|
|
427
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
428
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
429
|
+
import { tmpdir } from "node:os";
|
|
430
|
+
import { join as join3 } from "node:path";
|
|
431
|
+
function findCodexBinary() {
|
|
432
|
+
return whichBinary("codex");
|
|
433
|
+
}
|
|
434
|
+
var CodexProvider = class {
|
|
435
|
+
name = "codex";
|
|
436
|
+
model;
|
|
437
|
+
binary;
|
|
438
|
+
explicitModel;
|
|
439
|
+
constructor(cfg) {
|
|
440
|
+
this.binary = cfg.binary;
|
|
441
|
+
this.model = cfg.model ?? "chatgpt-default";
|
|
442
|
+
this.explicitModel = cfg.model ?? null;
|
|
443
|
+
}
|
|
444
|
+
async chat(opts2) {
|
|
445
|
+
const prompt = mergePrompt2(opts2);
|
|
446
|
+
const tmpDir = mkdtempSync(join3(tmpdir(), "stickyinc-codex-"));
|
|
447
|
+
const outFile = join3(tmpDir, "last.txt");
|
|
448
|
+
const args2 = [
|
|
449
|
+
"exec",
|
|
450
|
+
"--skip-git-repo-check",
|
|
451
|
+
"--sandbox",
|
|
452
|
+
"read-only",
|
|
453
|
+
"--output-last-message",
|
|
454
|
+
outFile
|
|
455
|
+
];
|
|
456
|
+
if (this.explicitModel) args2.push("--model", this.explicitModel);
|
|
457
|
+
args2.push(prompt);
|
|
458
|
+
const cleanup = () => {
|
|
459
|
+
try {
|
|
460
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
461
|
+
} catch {
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
return new Promise((resolve2, reject) => {
|
|
465
|
+
const child = spawn2(this.binary, args2, {
|
|
466
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
467
|
+
});
|
|
468
|
+
const timer = setTimeout(() => {
|
|
469
|
+
child.kill();
|
|
470
|
+
reject(new Error(`codex exec gave no answer within ${CHAT_TIMEOUT_MS / 1e3}s`));
|
|
471
|
+
}, CHAT_TIMEOUT_MS);
|
|
472
|
+
let out = "";
|
|
473
|
+
let err = "";
|
|
474
|
+
child.stdout.setEncoding("utf8");
|
|
475
|
+
child.stderr.setEncoding("utf8");
|
|
476
|
+
child.stdout.on("data", (chunk) => out += chunk);
|
|
477
|
+
child.stderr.on("data", (chunk) => err += chunk);
|
|
478
|
+
child.on("error", (e) => {
|
|
479
|
+
clearTimeout(timer);
|
|
480
|
+
cleanup();
|
|
481
|
+
reject(e);
|
|
482
|
+
});
|
|
483
|
+
child.on("close", (code) => {
|
|
484
|
+
clearTimeout(timer);
|
|
485
|
+
if (code !== 0) {
|
|
486
|
+
cleanup();
|
|
487
|
+
const stderrSnip = err.trim().slice(0, 400);
|
|
488
|
+
const stdoutSnip = out.trim().slice(0, 400);
|
|
489
|
+
reject(
|
|
490
|
+
new Error(
|
|
491
|
+
`codex exec exited ${code}: ${stderrSnip || stdoutSnip || "(no output)"}`
|
|
492
|
+
)
|
|
493
|
+
);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
let content = "";
|
|
497
|
+
try {
|
|
498
|
+
content = readFileSync(outFile, "utf8").trim();
|
|
499
|
+
} catch {
|
|
500
|
+
content = out.trim();
|
|
501
|
+
}
|
|
502
|
+
cleanup();
|
|
503
|
+
resolve2({
|
|
504
|
+
content,
|
|
505
|
+
model: this.model,
|
|
506
|
+
provider: this.name
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
function mergePrompt2(opts2) {
|
|
513
|
+
const parts = [];
|
|
514
|
+
if (opts2.system) parts.push(opts2.system);
|
|
515
|
+
for (const m of opts2.messages) {
|
|
516
|
+
if (m.role === "user") parts.push(m.content);
|
|
517
|
+
else parts.push(`[previous assistant reply]
|
|
518
|
+
${m.content}`);
|
|
519
|
+
}
|
|
520
|
+
return parts.join("\n\n");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/providers/gemini.ts
|
|
524
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
525
|
+
function findGeminiBinary() {
|
|
526
|
+
return whichBinary("gemini");
|
|
527
|
+
}
|
|
528
|
+
var GeminiProvider = class {
|
|
529
|
+
name = "gemini";
|
|
530
|
+
model;
|
|
531
|
+
binary;
|
|
532
|
+
explicitModel;
|
|
533
|
+
constructor(cfg) {
|
|
534
|
+
this.binary = cfg.binary;
|
|
535
|
+
this.model = cfg.model ?? "gemini-default";
|
|
536
|
+
this.explicitModel = cfg.model ?? null;
|
|
537
|
+
}
|
|
538
|
+
async chat(opts2) {
|
|
539
|
+
const prompt = mergePrompt3(opts2);
|
|
540
|
+
const args2 = [];
|
|
541
|
+
if (this.explicitModel) args2.push("--model", this.explicitModel);
|
|
542
|
+
args2.push("-p", prompt);
|
|
543
|
+
return new Promise((resolve2, reject) => {
|
|
544
|
+
const child = spawn3(this.binary, args2, {
|
|
545
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
546
|
+
});
|
|
547
|
+
const timer = setTimeout(() => {
|
|
548
|
+
child.kill();
|
|
549
|
+
reject(new Error(`gemini -p gave no answer within ${CHAT_TIMEOUT_MS / 1e3}s`));
|
|
550
|
+
}, CHAT_TIMEOUT_MS);
|
|
551
|
+
let out = "";
|
|
552
|
+
let err = "";
|
|
553
|
+
child.stdout.setEncoding("utf8");
|
|
554
|
+
child.stderr.setEncoding("utf8");
|
|
555
|
+
child.stdout.on("data", (chunk) => out += chunk);
|
|
556
|
+
child.stderr.on("data", (chunk) => err += chunk);
|
|
557
|
+
child.on("error", (e) => {
|
|
558
|
+
clearTimeout(timer);
|
|
559
|
+
reject(e);
|
|
560
|
+
});
|
|
561
|
+
child.on("close", (code) => {
|
|
562
|
+
clearTimeout(timer);
|
|
563
|
+
if (code !== 0) {
|
|
564
|
+
const stderrSnip = err.trim().slice(0, 400);
|
|
565
|
+
const stdoutSnip = out.trim().slice(0, 400);
|
|
566
|
+
reject(
|
|
567
|
+
new Error(
|
|
568
|
+
`gemini -p exited ${code}: ${stderrSnip || stdoutSnip || "(no output)"}`
|
|
569
|
+
)
|
|
570
|
+
);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
resolve2({
|
|
574
|
+
content: out.trim(),
|
|
575
|
+
model: this.model,
|
|
576
|
+
provider: this.name
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
function mergePrompt3(opts2) {
|
|
583
|
+
const parts = [];
|
|
584
|
+
if (opts2.system) parts.push(opts2.system);
|
|
585
|
+
for (const m of opts2.messages) {
|
|
586
|
+
if (m.role === "user") parts.push(m.content);
|
|
587
|
+
else parts.push(`[previous assistant reply]
|
|
588
|
+
${m.content}`);
|
|
589
|
+
}
|
|
590
|
+
return parts.join("\n\n");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// src/providers/local.ts
|
|
594
|
+
var LOCAL_ENDPOINTS = [
|
|
595
|
+
{ url: "http://127.0.0.1:11434", kind: "ollama" },
|
|
596
|
+
{ url: "http://127.0.0.1:1234", kind: "lm-studio" }
|
|
597
|
+
];
|
|
598
|
+
async function probeLocalProvider(overrideModel) {
|
|
599
|
+
for (const ep of LOCAL_ENDPOINTS) {
|
|
600
|
+
const model = await probe(ep);
|
|
601
|
+
if (model) {
|
|
602
|
+
return new OpenAICompatProvider({
|
|
603
|
+
api_key: "local",
|
|
604
|
+
model: overrideModel ?? model,
|
|
605
|
+
base_url: `${ep.url}/v1`,
|
|
606
|
+
provider_label: ep.kind
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
async function probe(ep) {
|
|
613
|
+
const controller = new AbortController();
|
|
614
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
615
|
+
try {
|
|
616
|
+
if (ep.kind === "ollama") {
|
|
617
|
+
const r2 = await fetch(`${ep.url}/api/tags`, { signal: controller.signal });
|
|
618
|
+
if (!r2.ok) return null;
|
|
619
|
+
const data2 = await r2.json();
|
|
620
|
+
return data2.models?.[0]?.name ?? null;
|
|
621
|
+
}
|
|
622
|
+
const r = await fetch(`${ep.url}/v1/models`, { signal: controller.signal });
|
|
623
|
+
if (!r.ok) return null;
|
|
624
|
+
const data = await r.json();
|
|
625
|
+
return data.data?.[0]?.id ?? null;
|
|
626
|
+
} catch {
|
|
627
|
+
return null;
|
|
628
|
+
} finally {
|
|
629
|
+
clearTimeout(timer);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// src/providers/index.ts
|
|
634
|
+
var CONFIG_PATH = join4(homedir3(), ".stickyinc", "llm.json");
|
|
635
|
+
function readConfigFile() {
|
|
636
|
+
if (!existsSync2(CONFIG_PATH)) return null;
|
|
637
|
+
try {
|
|
638
|
+
return JSON.parse(readFileSync2(CONFIG_PATH, "utf8"));
|
|
639
|
+
} catch {
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
var cached;
|
|
644
|
+
function configMtime() {
|
|
645
|
+
try {
|
|
646
|
+
return statSync(CONFIG_PATH).mtimeMs;
|
|
647
|
+
} catch {
|
|
648
|
+
return 0;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function resolveLLMProvider() {
|
|
652
|
+
const mtime = configMtime();
|
|
653
|
+
if (cached && cached.configMtime === mtime) return cached.provider;
|
|
654
|
+
const provider = await resolveInternal();
|
|
655
|
+
cached = provider ? { provider, configMtime: mtime } : void 0;
|
|
656
|
+
return provider;
|
|
657
|
+
}
|
|
658
|
+
async function resolveInternal() {
|
|
659
|
+
const cfg = readConfigFile();
|
|
660
|
+
if (cfg) {
|
|
661
|
+
switch (cfg.provider) {
|
|
662
|
+
case "anthropic": {
|
|
663
|
+
const key = cfg.api_key ?? process.env.ANTHROPIC_API_KEY;
|
|
664
|
+
if (!key) return null;
|
|
665
|
+
return new AnthropicProvider({ api_key: key, model: cfg.model, base_url: cfg.base_url });
|
|
666
|
+
}
|
|
667
|
+
case "openrouter": {
|
|
668
|
+
const key = cfg.api_key ?? process.env.OPENROUTER_API_KEY;
|
|
669
|
+
if (!key) return null;
|
|
670
|
+
return new OpenAICompatProvider({
|
|
671
|
+
api_key: key,
|
|
672
|
+
model: cfg.model ?? "anthropic/claude-haiku-4.5",
|
|
673
|
+
base_url: cfg.base_url ?? "https://openrouter.ai/api/v1",
|
|
674
|
+
provider_label: "openrouter",
|
|
675
|
+
extra_headers: {
|
|
676
|
+
"HTTP-Referer": "https://github.com/Astralchemist/stickyinc",
|
|
677
|
+
"X-Title": "StickyInc",
|
|
678
|
+
...cfg.extra_headers ?? {}
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
case "openai": {
|
|
683
|
+
const key = cfg.api_key ?? process.env.OPENAI_API_KEY;
|
|
684
|
+
if (!key) return null;
|
|
685
|
+
return new OpenAICompatProvider({
|
|
686
|
+
api_key: key,
|
|
687
|
+
model: cfg.model ?? "gpt-4o-mini",
|
|
688
|
+
base_url: cfg.base_url ?? "https://api.openai.com/v1",
|
|
689
|
+
provider_label: "openai"
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
case "compat": {
|
|
693
|
+
if (!cfg.api_key || !cfg.model || !cfg.base_url) return null;
|
|
694
|
+
return new OpenAICompatProvider({
|
|
695
|
+
api_key: cfg.api_key,
|
|
696
|
+
model: cfg.model,
|
|
697
|
+
base_url: cfg.base_url,
|
|
698
|
+
provider_label: "compat",
|
|
699
|
+
extra_headers: cfg.extra_headers
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
case "claude-code": {
|
|
703
|
+
const binary = findClaudeBinary();
|
|
704
|
+
if (!binary) return null;
|
|
705
|
+
return new ClaudeCodeProvider({ binary, model: cfg.model });
|
|
706
|
+
}
|
|
707
|
+
case "codex": {
|
|
708
|
+
const binary = findCodexBinary();
|
|
709
|
+
if (!binary) return null;
|
|
710
|
+
return new CodexProvider({ binary, model: cfg.model });
|
|
711
|
+
}
|
|
712
|
+
case "gemini": {
|
|
713
|
+
const binary = findGeminiBinary();
|
|
714
|
+
if (!binary) return null;
|
|
715
|
+
return new GeminiProvider({ binary, model: cfg.model });
|
|
716
|
+
}
|
|
717
|
+
case "local": {
|
|
718
|
+
return probeLocalProvider(cfg.model);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (process.env.OPENROUTER_API_KEY) {
|
|
723
|
+
return new OpenAICompatProvider({
|
|
724
|
+
api_key: process.env.OPENROUTER_API_KEY,
|
|
725
|
+
model: process.env.STICKYINC_MODEL ?? "anthropic/claude-haiku-4.5",
|
|
726
|
+
base_url: "https://openrouter.ai/api/v1",
|
|
727
|
+
provider_label: "openrouter",
|
|
728
|
+
extra_headers: {
|
|
729
|
+
"HTTP-Referer": "https://github.com/Astralchemist/stickyinc",
|
|
730
|
+
"X-Title": "StickyInc"
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
735
|
+
return new AnthropicProvider({
|
|
736
|
+
api_key: process.env.ANTHROPIC_API_KEY,
|
|
737
|
+
model: process.env.STICKYINC_MODEL
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
if (process.env.OPENAI_API_KEY) {
|
|
741
|
+
return new OpenAICompatProvider({
|
|
742
|
+
api_key: process.env.OPENAI_API_KEY,
|
|
743
|
+
model: process.env.STICKYINC_MODEL ?? "gpt-4o-mini",
|
|
744
|
+
base_url: "https://api.openai.com/v1",
|
|
745
|
+
provider_label: "openai"
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
const claudeBin = findClaudeBinary();
|
|
749
|
+
if (claudeBin) {
|
|
750
|
+
return new ClaudeCodeProvider({ binary: claudeBin, model: process.env.STICKYINC_MODEL });
|
|
751
|
+
}
|
|
752
|
+
const codexBin = findCodexBinary();
|
|
753
|
+
if (codexBin) {
|
|
754
|
+
return new CodexProvider({ binary: codexBin, model: process.env.STICKYINC_MODEL });
|
|
755
|
+
}
|
|
756
|
+
const geminiBin = findGeminiBinary();
|
|
757
|
+
if (geminiBin) {
|
|
758
|
+
return new GeminiProvider({ binary: geminiBin, model: process.env.STICKYINC_MODEL });
|
|
759
|
+
}
|
|
760
|
+
const local = await probeLocalProvider(process.env.STICKYINC_MODEL);
|
|
761
|
+
if (local) return local;
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/watcher.ts
|
|
766
|
+
var CLAUDE_PROJECTS = join5(homedir4(), ".claude", "projects");
|
|
767
|
+
var STATE_FILE = join5(homedir4(), ".stickyinc", "watcher-state.json");
|
|
768
|
+
function loadState() {
|
|
769
|
+
if (!existsSync3(STATE_FILE)) return { files: {} };
|
|
770
|
+
try {
|
|
771
|
+
return JSON.parse(readFileSync3(STATE_FILE, "utf8"));
|
|
772
|
+
} catch {
|
|
773
|
+
return { files: {} };
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function saveState(s) {
|
|
777
|
+
writeFileSync(STATE_FILE, JSON.stringify(s, null, 2));
|
|
778
|
+
}
|
|
779
|
+
function readBytes(file, start, end) {
|
|
780
|
+
const buf = Buffer.alloc(end - start);
|
|
781
|
+
const fd = openSync(file, "r");
|
|
782
|
+
try {
|
|
783
|
+
let read = 0;
|
|
784
|
+
while (read < buf.length) {
|
|
785
|
+
const n = readSync(fd, buf, read, buf.length - read, start + read);
|
|
786
|
+
if (n === 0) break;
|
|
787
|
+
read += n;
|
|
788
|
+
}
|
|
789
|
+
return buf.subarray(0, read);
|
|
790
|
+
} finally {
|
|
791
|
+
closeSync(fd);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
function listJsonlFiles() {
|
|
795
|
+
if (!existsSync3(CLAUDE_PROJECTS)) return [];
|
|
796
|
+
const out = [];
|
|
797
|
+
for (const dir of readdirSync(CLAUDE_PROJECTS)) {
|
|
798
|
+
const full = join5(CLAUDE_PROJECTS, dir);
|
|
799
|
+
try {
|
|
800
|
+
if (!statSync2(full).isDirectory()) continue;
|
|
801
|
+
for (const f of readdirSync(full)) {
|
|
802
|
+
if (f.endsWith(".jsonl")) out.push(join5(full, f));
|
|
803
|
+
}
|
|
804
|
+
} catch {
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
return out;
|
|
808
|
+
}
|
|
809
|
+
function extractText(message) {
|
|
810
|
+
const c = message?.content;
|
|
811
|
+
if (typeof c === "string") return c;
|
|
812
|
+
if (Array.isArray(c)) {
|
|
813
|
+
return c.map((b) => {
|
|
814
|
+
if (typeof b === "string") return b;
|
|
815
|
+
if (b && typeof b === "object" && "text" in b) return String(b.text ?? "");
|
|
816
|
+
return "";
|
|
817
|
+
}).join(" ").trim();
|
|
818
|
+
}
|
|
819
|
+
return "";
|
|
820
|
+
}
|
|
821
|
+
var EXTRACTION_SYSTEM = `You extract actionable commitments from a message.
|
|
822
|
+
|
|
823
|
+
A "commitment" is something the speaker said they will or should do. Examples:
|
|
824
|
+
- "I need to call the dentist" \u2192 { "text": "Call the dentist", "due_at": null }
|
|
825
|
+
- "Let me email Sarah tomorrow" \u2192 { "text": "Email Sarah", "due_at": "<tomorrow's date>T09:00" }
|
|
826
|
+
|
|
827
|
+
Ignore:
|
|
828
|
+
- Hypotheticals ("I could do X")
|
|
829
|
+
- Rhetorical or past-tense references
|
|
830
|
+
- Generic questions or musings
|
|
831
|
+
|
|
832
|
+
Output ONLY a JSON object, no prose:
|
|
833
|
+
{ "commitments": [{ "text": "...", "due_at": "<local YYYY-MM-DDTHH:MM or null>" }] }
|
|
834
|
+
|
|
835
|
+
Empty array if nothing qualifies. due_at is the user's local time, with no offset or Z; take weekdays and "tomorrow" from the dates listed below. If a date has no time, use 09:00.`;
|
|
836
|
+
function stripFences(s) {
|
|
837
|
+
return s.replace(/^\s*```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
|
|
838
|
+
}
|
|
839
|
+
function parseCommitments(raw) {
|
|
840
|
+
let cleaned = stripFences(raw);
|
|
841
|
+
let obj;
|
|
842
|
+
try {
|
|
843
|
+
obj = JSON.parse(cleaned);
|
|
844
|
+
} catch {
|
|
845
|
+
const m = cleaned.match(/\{[\s\S]*\}/);
|
|
846
|
+
if (!m) return [];
|
|
847
|
+
try {
|
|
848
|
+
obj = JSON.parse(m[0]);
|
|
849
|
+
} catch {
|
|
850
|
+
return [];
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (!obj || typeof obj !== "object") return [];
|
|
854
|
+
const arr = obj.commitments;
|
|
855
|
+
if (!Array.isArray(arr)) return [];
|
|
856
|
+
const out = [];
|
|
857
|
+
for (const c of arr) {
|
|
858
|
+
if (!c || typeof c !== "object") continue;
|
|
859
|
+
const text = c.text;
|
|
860
|
+
const due = c.due_at;
|
|
861
|
+
if (typeof text === "string" && text.trim().length > 0) {
|
|
862
|
+
out.push({
|
|
863
|
+
text: text.trim(),
|
|
864
|
+
due_at: typeof due === "string" ? toStoredDue(due) : null
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return out;
|
|
869
|
+
}
|
|
870
|
+
async function extract(provider, speaker, text) {
|
|
871
|
+
if (text.trim().length < 4) return [];
|
|
872
|
+
const res = await provider.chat({
|
|
873
|
+
system: EXTRACTION_SYSTEM,
|
|
874
|
+
messages: [
|
|
875
|
+
{
|
|
876
|
+
role: "user",
|
|
877
|
+
content: `${timeContext()}
|
|
878
|
+
Speaker: ${speaker}
|
|
879
|
+
|
|
880
|
+
Message:
|
|
881
|
+
${text.slice(0, 4e3)}`
|
|
882
|
+
}
|
|
883
|
+
],
|
|
884
|
+
response_format: "json",
|
|
885
|
+
max_tokens: 400,
|
|
886
|
+
temperature: 0
|
|
887
|
+
});
|
|
888
|
+
return parseCommitments(res.content);
|
|
889
|
+
}
|
|
890
|
+
async function runWatcher(opts2 = {}) {
|
|
891
|
+
const interval = opts2.intervalMs ?? 3e3;
|
|
892
|
+
const includeUser = opts2.includeUser ?? true;
|
|
893
|
+
const includeAssistant = opts2.includeAssistant ?? false;
|
|
894
|
+
const verbose = opts2.verbose ?? true;
|
|
895
|
+
const provider = await resolveLLMProvider();
|
|
896
|
+
if (!provider) {
|
|
897
|
+
console.error(
|
|
898
|
+
"No LLM configured. StickyInc auto-uses whatever is on this machine: claude / codex / gemini CLI (subscription), Ollama or LM Studio (local), or an API key via OPENROUTER_API_KEY / ANTHROPIC_API_KEY / OPENAI_API_KEY. Or create ~/.stickyinc/llm.json."
|
|
899
|
+
);
|
|
900
|
+
process.exit(1);
|
|
901
|
+
}
|
|
902
|
+
if (!existsSync3(CLAUDE_PROJECTS)) {
|
|
903
|
+
console.error(`No Claude Code transcripts at ${CLAUDE_PROJECTS}. Is Claude Code installed?`);
|
|
904
|
+
process.exit(1);
|
|
905
|
+
}
|
|
906
|
+
console.error(
|
|
907
|
+
`StickyInc watcher running.
|
|
908
|
+
Transcripts: ${CLAUDE_PROJECTS}
|
|
909
|
+
Provider: ${provider.name} (${provider.model})
|
|
910
|
+
Extracting: ${[includeUser && "user", includeAssistant && "assistant"].filter(Boolean).join(", ")}
|
|
911
|
+
Poll: ${interval}ms
|
|
912
|
+
Press Ctrl-C to stop.
|
|
913
|
+
`
|
|
914
|
+
);
|
|
915
|
+
const state = loadState();
|
|
916
|
+
for (const f of listJsonlFiles()) {
|
|
917
|
+
if (!state.files[f]) {
|
|
918
|
+
const st = statSync2(f);
|
|
919
|
+
state.files[f] = { size: st.size, mtime: st.mtimeMs };
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
saveState(state);
|
|
923
|
+
const tick = async () => {
|
|
924
|
+
for (const file of listJsonlFiles()) {
|
|
925
|
+
let st;
|
|
926
|
+
try {
|
|
927
|
+
st = statSync2(file);
|
|
928
|
+
} catch {
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
const prev = state.files[file] ?? { size: 0, mtime: 0 };
|
|
932
|
+
if (st.size < prev.size) prev.size = 0;
|
|
933
|
+
if (st.size === prev.size) continue;
|
|
934
|
+
const chunk = readBytes(file, prev.size, st.size);
|
|
935
|
+
const end = chunk.lastIndexOf(10) + 1;
|
|
936
|
+
if (end === 0) continue;
|
|
937
|
+
const slice = chunk.subarray(0, end).toString("utf8");
|
|
938
|
+
prev.size += end;
|
|
939
|
+
prev.mtime = st.mtimeMs;
|
|
940
|
+
state.files[file] = prev;
|
|
941
|
+
for (const line of slice.split("\n")) {
|
|
942
|
+
if (!line.trim()) continue;
|
|
943
|
+
let obj;
|
|
944
|
+
try {
|
|
945
|
+
obj = JSON.parse(line);
|
|
946
|
+
} catch {
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
const role = obj.message?.role;
|
|
950
|
+
if (obj.type !== "user" && obj.type !== "assistant") continue;
|
|
951
|
+
if (role === "user" && !includeUser) continue;
|
|
952
|
+
if (role === "assistant" && !includeAssistant) continue;
|
|
953
|
+
const text = extractText(obj.message);
|
|
954
|
+
if (!text) continue;
|
|
955
|
+
try {
|
|
956
|
+
const commitments = await extract(provider, role ?? "user", text);
|
|
957
|
+
for (const c of commitments) {
|
|
958
|
+
const { task, inserted } = addTaskUnique(c.text, c.due_at, "passive-extract");
|
|
959
|
+
if (inserted && verbose) {
|
|
960
|
+
console.error(` + #${task.id} ${c.text}${c.due_at ? ` (due ${c.due_at})` : ""}`);
|
|
961
|
+
} else if (!inserted && verbose) {
|
|
962
|
+
console.error(` ~ dup #${task.id} ${c.text}`);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
} catch (err) {
|
|
966
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
967
|
+
if (verbose) console.error(` ! extract failed: ${msg}`);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
saveState(state);
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
let stopping = false;
|
|
974
|
+
const onExit = () => {
|
|
975
|
+
stopping = true;
|
|
976
|
+
saveState(state);
|
|
977
|
+
console.error("\nwatcher stopped.");
|
|
978
|
+
process.exit(0);
|
|
979
|
+
};
|
|
980
|
+
process.on("SIGINT", onExit);
|
|
981
|
+
process.on("SIGTERM", onExit);
|
|
982
|
+
const parentPid = process.ppid;
|
|
983
|
+
while (!stopping) {
|
|
984
|
+
if (opts2.exitWithParent && process.ppid !== parentPid) {
|
|
985
|
+
console.error("parent exited; watcher stopping.");
|
|
986
|
+
onExit();
|
|
987
|
+
}
|
|
988
|
+
try {
|
|
989
|
+
await tick();
|
|
990
|
+
} catch (err) {
|
|
991
|
+
if (verbose) console.error(` ! tick error: ${err instanceof Error ? err.message : err}`);
|
|
992
|
+
}
|
|
993
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// src/watch-cli.ts
|
|
998
|
+
var args = process.argv.slice(2);
|
|
999
|
+
var opts = {
|
|
1000
|
+
includeAssistant: args.includes("--assistant") || args.includes("-a"),
|
|
1001
|
+
includeUser: !args.includes("--no-user"),
|
|
1002
|
+
intervalMs: (() => {
|
|
1003
|
+
const i = args.findIndex((a) => a === "--interval" || a === "-i");
|
|
1004
|
+
if (i >= 0 && args[i + 1]) return Math.max(500, Number(args[i + 1]) || 3e3);
|
|
1005
|
+
return 3e3;
|
|
1006
|
+
})(),
|
|
1007
|
+
verbose: !args.includes("--quiet") && !args.includes("-q"),
|
|
1008
|
+
exitWithParent: args.includes("--exit-with-parent")
|
|
1009
|
+
};
|
|
1010
|
+
runWatcher(opts).catch((err) => {
|
|
1011
|
+
console.error(err instanceof Error ? err.message : err);
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
});
|