portable-agent-layer 0.22.0 → 0.23.1
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/assets/agents/gemini-researcher.md +17 -3
- package/assets/agents/grok-researcher.md +19 -5
- package/assets/agents/multi-perspective-researcher.md +16 -2
- package/assets/agents/perplexity-researcher.md +17 -3
- package/assets/skills/analyze-pdf/SKILL.md +1 -1
- package/assets/skills/analyze-youtube/SKILL.md +1 -1
- package/assets/skills/extract-entities/SKILL.md +1 -1
- package/assets/skills/fyzz-chat-api/SKILL.md +3 -3
- package/assets/skills/reflect/SKILL.md +2 -2
- package/assets/skills/telos/SKILL.md +6 -6
- package/assets/templates/AGENTS.md.template +2 -2
- package/assets/templates/PAL/ALGORITHM.md +93 -10
- package/assets/templates/PAL/CONTEXT_ROUTING.md +17 -17
- package/assets/templates/PAL/MEMORY_SYSTEM.md +5 -5
- package/assets/templates/PAL/README.md +12 -9
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
- package/assets/templates/pal-settings.json +6 -3
- package/assets/templates/settings.claude.json +2 -2
- package/package.json +3 -1
- package/src/cli/index.ts +4 -11
- package/src/hooks/handlers/failure.ts +3 -1
- package/src/hooks/handlers/rating.ts +17 -2
- package/src/hooks/handlers/reflect-trigger.ts +4 -4
- package/src/hooks/handlers/relationship.ts +1 -1
- package/src/hooks/handlers/session-intelligence.ts +324 -0
- package/src/hooks/handlers/session-name.ts +2 -2
- package/src/hooks/handlers/synthesis.ts +36 -0
- package/src/hooks/handlers/update-check.ts +2 -2
- package/src/hooks/handlers/work-learning.ts +1 -1
- package/src/hooks/lib/context.ts +119 -2
- package/src/hooks/lib/paths.ts +4 -12
- package/src/hooks/lib/security.ts +39 -28
- package/src/hooks/lib/stop.ts +56 -7
- package/src/hooks/lib/token-usage.ts +1 -0
- package/src/targets/claude/install.ts +1 -1
- package/src/targets/cursor/install.ts +7 -1
- package/src/targets/cursor/uninstall.ts +7 -0
- package/src/targets/lib.ts +125 -115
- package/src/targets/opencode/install.ts +4 -4
- package/src/tools/agent/algorithm-reflect.ts +2 -0
- package/src/tools/agent/synthesize.ts +361 -0
- package/src/tools/agent/thread.ts +162 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Synthesize — Aggregate recent PAL activity into compact state for hook injection.
|
|
4
|
+
*
|
|
5
|
+
* Deterministic (no LLM call). Reads threads, reflections, session notes,
|
|
6
|
+
* and ratings, then writes compact JSON state that the session-start hook
|
|
7
|
+
* reads and formats with behavioral guidance.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* bun ~/.pal/tools/synthesize.ts [--days 7] [--force]
|
|
11
|
+
*
|
|
12
|
+
* Guards: skips if last synthesis was < 24h ago (unless --force).
|
|
13
|
+
* Output: ~/.pal/memory/state/synthesis.json
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
20
|
+
|
|
21
|
+
// ── Config ──
|
|
22
|
+
|
|
23
|
+
const SYNTHESIS_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
24
|
+
|
|
25
|
+
// ── Types ──
|
|
26
|
+
|
|
27
|
+
interface SynthesisState {
|
|
28
|
+
timestamp: string;
|
|
29
|
+
days: number;
|
|
30
|
+
threads: { id: string; cwd?: string; title: string; context: string; opened: string }[];
|
|
31
|
+
sessions: { date: string; titles: string[] }[];
|
|
32
|
+
sessionCount: number;
|
|
33
|
+
ratings: {
|
|
34
|
+
count: number;
|
|
35
|
+
avg: number;
|
|
36
|
+
recentAvg: number;
|
|
37
|
+
lowCount: number;
|
|
38
|
+
trend: "improving" | "declining" | "stable";
|
|
39
|
+
};
|
|
40
|
+
algorithm: {
|
|
41
|
+
reflectionCount: number;
|
|
42
|
+
avgSentiment: number;
|
|
43
|
+
passRate: number;
|
|
44
|
+
criteriaTotal: number;
|
|
45
|
+
criteriaPassed: number;
|
|
46
|
+
recentObservations: {
|
|
47
|
+
date: string;
|
|
48
|
+
cwd?: string;
|
|
49
|
+
task: string;
|
|
50
|
+
observation: string;
|
|
51
|
+
}[];
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Helpers ──
|
|
56
|
+
|
|
57
|
+
function stateDir(): string {
|
|
58
|
+
return ensureDir(paths.state());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function synthesisPath(): string {
|
|
62
|
+
return resolve(stateDir(), "synthesis.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function shouldRun(force: boolean): boolean {
|
|
66
|
+
if (force) return true;
|
|
67
|
+
const p = synthesisPath();
|
|
68
|
+
if (!existsSync(p)) return true;
|
|
69
|
+
try {
|
|
70
|
+
const data = JSON.parse(readFileSync(p, "utf-8")) as { timestamp: string };
|
|
71
|
+
return Date.now() - new Date(data.timestamp).getTime() > SYNTHESIS_TTL_MS;
|
|
72
|
+
} catch {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readJsonl<T>(path: string): T[] {
|
|
78
|
+
if (!existsSync(path)) return [];
|
|
79
|
+
return readFileSync(path, "utf-8")
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter((l) => l.trim())
|
|
82
|
+
.map((l) => JSON.parse(l) as T);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function daysAgo(days: number): Date {
|
|
86
|
+
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatDate(iso: string): string {
|
|
90
|
+
return iso.slice(0, 10);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function safeReaddir(dir: string): string[] {
|
|
94
|
+
try {
|
|
95
|
+
return readdirSync(dir);
|
|
96
|
+
} catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Data readers ──
|
|
102
|
+
|
|
103
|
+
interface Thread {
|
|
104
|
+
id: string;
|
|
105
|
+
cwd?: string;
|
|
106
|
+
title: string;
|
|
107
|
+
context: string;
|
|
108
|
+
status: string;
|
|
109
|
+
created: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getOpenThreads(): SynthesisState["threads"] {
|
|
113
|
+
const threads = readJsonl<Thread>(resolve(stateDir(), "threads.jsonl"));
|
|
114
|
+
return threads
|
|
115
|
+
.filter((t) => t.status === "open")
|
|
116
|
+
.map((t) => ({
|
|
117
|
+
id: t.id,
|
|
118
|
+
cwd: t.cwd,
|
|
119
|
+
title: t.title,
|
|
120
|
+
context: t.context,
|
|
121
|
+
opened: formatDate(t.created),
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Reflection {
|
|
126
|
+
timestamp: string;
|
|
127
|
+
cwd?: string;
|
|
128
|
+
task: string;
|
|
129
|
+
criteria_count: number;
|
|
130
|
+
criteria_passed: number;
|
|
131
|
+
criteria_failed: number;
|
|
132
|
+
sentiment: number;
|
|
133
|
+
q1: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function getAlgorithmStats(since: Date): SynthesisState["algorithm"] {
|
|
137
|
+
const p = resolve(
|
|
138
|
+
ensureDir(resolve(paths.learning(), "reflections")),
|
|
139
|
+
"algorithm-reflections.jsonl"
|
|
140
|
+
);
|
|
141
|
+
const reflections = readJsonl<Reflection>(p).filter(
|
|
142
|
+
(r) => new Date(r.timestamp) >= since
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (reflections.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
reflectionCount: 0,
|
|
148
|
+
avgSentiment: 0,
|
|
149
|
+
passRate: 0,
|
|
150
|
+
criteriaTotal: 0,
|
|
151
|
+
criteriaPassed: 0,
|
|
152
|
+
recentObservations: [],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const avgSentiment =
|
|
157
|
+
reflections.reduce((s, r) => s + r.sentiment, 0) / reflections.length;
|
|
158
|
+
const criteriaTotal = reflections.reduce((s, r) => s + r.criteria_count, 0);
|
|
159
|
+
const criteriaPassed = reflections.reduce((s, r) => s + r.criteria_passed, 0);
|
|
160
|
+
const passRate =
|
|
161
|
+
criteriaTotal > 0 ? Math.round((criteriaPassed / criteriaTotal) * 100) : 0;
|
|
162
|
+
|
|
163
|
+
const recentObservations = reflections.slice(-3).map((r) => ({
|
|
164
|
+
date: formatDate(r.timestamp),
|
|
165
|
+
cwd: r.cwd,
|
|
166
|
+
task: r.task,
|
|
167
|
+
observation: r.q1,
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
reflectionCount: reflections.length,
|
|
172
|
+
avgSentiment: Math.round(avgSentiment * 10) / 10,
|
|
173
|
+
passRate,
|
|
174
|
+
criteriaTotal,
|
|
175
|
+
criteriaPassed,
|
|
176
|
+
recentObservations,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface Rating {
|
|
181
|
+
ts: string;
|
|
182
|
+
rating: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getRatingStats(since: Date): SynthesisState["ratings"] {
|
|
186
|
+
const p = resolve(paths.signals(), "ratings.jsonl");
|
|
187
|
+
const ratings = readJsonl<Rating>(p).filter((r) => new Date(r.ts) >= since);
|
|
188
|
+
|
|
189
|
+
if (ratings.length === 0) {
|
|
190
|
+
return { count: 0, avg: 0, recentAvg: 0, lowCount: 0, trend: "stable" };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const avg = ratings.reduce((s, r) => s + r.rating, 0) / ratings.length;
|
|
194
|
+
const recent = ratings.slice(-10);
|
|
195
|
+
const recentAvg = recent.reduce((s, r) => s + r.rating, 0) / recent.length;
|
|
196
|
+
const lowCount = ratings.filter((r) => r.rating <= 3).length;
|
|
197
|
+
|
|
198
|
+
// Trend: compare first half to second half
|
|
199
|
+
const mid = Math.floor(ratings.length / 2);
|
|
200
|
+
if (mid < 3) {
|
|
201
|
+
return {
|
|
202
|
+
count: ratings.length,
|
|
203
|
+
avg: round1(avg),
|
|
204
|
+
recentAvg: round1(recentAvg),
|
|
205
|
+
lowCount,
|
|
206
|
+
trend: "stable",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const firstHalf = ratings.slice(0, mid);
|
|
210
|
+
const secondHalf = ratings.slice(mid);
|
|
211
|
+
const firstAvg = firstHalf.reduce((s, r) => s + r.rating, 0) / firstHalf.length;
|
|
212
|
+
const secondAvg = secondHalf.reduce((s, r) => s + r.rating, 0) / secondHalf.length;
|
|
213
|
+
const trend =
|
|
214
|
+
secondAvg - firstAvg > 0.5
|
|
215
|
+
? "improving"
|
|
216
|
+
: secondAvg - firstAvg < -0.5
|
|
217
|
+
? "declining"
|
|
218
|
+
: "stable";
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
count: ratings.length,
|
|
222
|
+
avg: round1(avg),
|
|
223
|
+
recentAvg: round1(recentAvg),
|
|
224
|
+
lowCount,
|
|
225
|
+
trend,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function round1(n: number): number {
|
|
230
|
+
return Math.round(n * 10) / 10;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getRecentSessions(since: Date): {
|
|
234
|
+
sessions: SynthesisState["sessions"];
|
|
235
|
+
count: number;
|
|
236
|
+
} {
|
|
237
|
+
const baseDir = resolve(paths.learning(), "session");
|
|
238
|
+
if (!existsSync(baseDir)) return { sessions: [], count: 0 };
|
|
239
|
+
|
|
240
|
+
const sinceStr = formatDate(since.toISOString());
|
|
241
|
+
const byDate = new Map<string, string[]>();
|
|
242
|
+
|
|
243
|
+
for (const year of safeReaddir(baseDir)) {
|
|
244
|
+
const yearDir = resolve(baseDir, year);
|
|
245
|
+
for (const month of safeReaddir(yearDir)) {
|
|
246
|
+
const monthDir = resolve(yearDir, month);
|
|
247
|
+
for (const file of safeReaddir(monthDir).filter((f) => f.endsWith(".md"))) {
|
|
248
|
+
const dateStr = file.slice(0, 8);
|
|
249
|
+
const isoDate = `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`;
|
|
250
|
+
if (isoDate < sinceStr) continue;
|
|
251
|
+
|
|
252
|
+
const content = readFileSync(resolve(monthDir, file), "utf-8");
|
|
253
|
+
const titleMatch =
|
|
254
|
+
content.match(/^title:\s*"?(.+?)"?\s*$/m) ??
|
|
255
|
+
content.match(/^\*\*Title:\*\*\s*(.+?)\s*$/m);
|
|
256
|
+
const title = titleMatch?.[1] ?? file.replace(/\.md$/, "");
|
|
257
|
+
|
|
258
|
+
const existing = byDate.get(isoDate) ?? [];
|
|
259
|
+
existing.push(title);
|
|
260
|
+
byDate.set(isoDate, existing);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sessions = [...byDate.entries()]
|
|
266
|
+
.sort((a, b) => b[0].localeCompare(a[0]))
|
|
267
|
+
.map(([date, titles]) => ({ date, titles }));
|
|
268
|
+
|
|
269
|
+
const count = sessions.reduce((s, d) => s + d.titles.length, 0);
|
|
270
|
+
return { sessions, count };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Synthesize ──
|
|
274
|
+
|
|
275
|
+
export function writeSynthesis(state: SynthesisState): string {
|
|
276
|
+
const sp = synthesisPath();
|
|
277
|
+
writeFileSync(sp, JSON.stringify(state, null, 2), "utf-8");
|
|
278
|
+
return sp;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function synthesize(days: number): SynthesisState {
|
|
282
|
+
const since = daysAgo(days);
|
|
283
|
+
const threads = getOpenThreads();
|
|
284
|
+
const { sessions, count: sessionCount } = getRecentSessions(since);
|
|
285
|
+
const ratings = getRatingStats(since);
|
|
286
|
+
const algorithm = getAlgorithmStats(since);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
timestamp: new Date().toISOString(),
|
|
290
|
+
days,
|
|
291
|
+
threads,
|
|
292
|
+
sessions,
|
|
293
|
+
sessionCount,
|
|
294
|
+
ratings,
|
|
295
|
+
algorithm,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── CLI ──
|
|
300
|
+
|
|
301
|
+
function run() {
|
|
302
|
+
const { values } = parseArgs({
|
|
303
|
+
args: Bun.argv.slice(2),
|
|
304
|
+
options: {
|
|
305
|
+
days: { type: "string", default: "7" },
|
|
306
|
+
force: { type: "boolean" },
|
|
307
|
+
help: { type: "boolean", short: "h" },
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
if (values.help) {
|
|
312
|
+
console.log(`
|
|
313
|
+
Synthesize — Aggregate recent PAL activity into compact state
|
|
314
|
+
|
|
315
|
+
Usage:
|
|
316
|
+
synthesize.ts [--days 7] [--force]
|
|
317
|
+
|
|
318
|
+
Options:
|
|
319
|
+
--days Lookback window (default: 7)
|
|
320
|
+
--force Skip 24h guard
|
|
321
|
+
|
|
322
|
+
Output: ~/.pal/memory/state/synthesis.json
|
|
323
|
+
`);
|
|
324
|
+
process.exit(0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const force = values.force ?? false;
|
|
328
|
+
if (!shouldRun(force)) {
|
|
329
|
+
console.log(
|
|
330
|
+
JSON.stringify({
|
|
331
|
+
skipped: true,
|
|
332
|
+
message: "Last synthesis < 24h ago. Use --force to override.",
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const days = parseInt(values.days ?? "7", 10);
|
|
339
|
+
const state = synthesize(days);
|
|
340
|
+
const sp = synthesisPath();
|
|
341
|
+
|
|
342
|
+
writeFileSync(sp, JSON.stringify(state, null, 2), "utf-8");
|
|
343
|
+
|
|
344
|
+
console.log(
|
|
345
|
+
JSON.stringify(
|
|
346
|
+
{
|
|
347
|
+
success: true,
|
|
348
|
+
path: sp,
|
|
349
|
+
threads: state.threads.length,
|
|
350
|
+
sessions: state.sessionCount,
|
|
351
|
+
ratings: state.ratings.count,
|
|
352
|
+
reflections: state.algorithm.reflectionCount,
|
|
353
|
+
message: `Synthesis written (${days}-day window)`,
|
|
354
|
+
},
|
|
355
|
+
null,
|
|
356
|
+
2
|
|
357
|
+
)
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (import.meta.main) run();
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Thread — Manage open threads across sessions.
|
|
4
|
+
*
|
|
5
|
+
* Threads are unresolved questions, decisions, or tasks that survive session boundaries.
|
|
6
|
+
* Stored in memory/state/threads.jsonl as structured records.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun ~/.pal/tools/thread.ts --add --title "..." [--context "..."]
|
|
10
|
+
* bun ~/.pal/tools/thread.ts --resolve --id <id>
|
|
11
|
+
* bun ~/.pal/tools/thread.ts --list [--all]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { parseArgs } from "node:util";
|
|
17
|
+
import { ensureDir, paths } from "../../hooks/lib/paths";
|
|
18
|
+
|
|
19
|
+
// ── Types ──
|
|
20
|
+
|
|
21
|
+
interface Thread {
|
|
22
|
+
id: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
title: string;
|
|
25
|
+
context: string;
|
|
26
|
+
status: "open" | "resolved";
|
|
27
|
+
created: string;
|
|
28
|
+
resolved: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Storage ──
|
|
32
|
+
|
|
33
|
+
function threadsPath(): string {
|
|
34
|
+
return resolve(ensureDir(paths.state()), "threads.jsonl");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function generateId(): string {
|
|
38
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readThreads(): Thread[] {
|
|
42
|
+
const p = threadsPath();
|
|
43
|
+
if (!existsSync(p)) return [];
|
|
44
|
+
return readFileSync(p, "utf-8")
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((l) => l.trim())
|
|
47
|
+
.map((l) => JSON.parse(l) as Thread);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeThreads(threads: Thread[]): void {
|
|
51
|
+
writeFileSync(
|
|
52
|
+
threadsPath(),
|
|
53
|
+
`${threads.map((t) => JSON.stringify(t)).join("\n")}\n`,
|
|
54
|
+
"utf-8"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Operations ──
|
|
59
|
+
|
|
60
|
+
function addThread(title: string, context: string): Thread {
|
|
61
|
+
const thread: Thread = {
|
|
62
|
+
id: generateId(),
|
|
63
|
+
cwd: process.cwd(),
|
|
64
|
+
title,
|
|
65
|
+
context,
|
|
66
|
+
status: "open",
|
|
67
|
+
created: new Date().toISOString(),
|
|
68
|
+
resolved: null,
|
|
69
|
+
};
|
|
70
|
+
appendFileSync(threadsPath(), `${JSON.stringify(thread)}\n`, "utf-8");
|
|
71
|
+
return thread;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveThread(id: string): {
|
|
75
|
+
success: boolean;
|
|
76
|
+
thread?: Thread;
|
|
77
|
+
message: string;
|
|
78
|
+
} {
|
|
79
|
+
const threads = readThreads();
|
|
80
|
+
const idx = threads.findIndex((t) => t.id === id);
|
|
81
|
+
if (idx === -1) return { success: false, message: `Thread not found: ${id}` };
|
|
82
|
+
threads[idx].status = "resolved";
|
|
83
|
+
threads[idx].resolved = new Date().toISOString();
|
|
84
|
+
writeThreads(threads);
|
|
85
|
+
return {
|
|
86
|
+
success: true,
|
|
87
|
+
thread: threads[idx],
|
|
88
|
+
message: `Resolved: ${threads[idx].title}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function listThreads(all: boolean): Thread[] {
|
|
93
|
+
return all ? readThreads() : readThreads().filter((t) => t.status === "open");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── CLI ──
|
|
97
|
+
|
|
98
|
+
function run() {
|
|
99
|
+
const { values } = parseArgs({
|
|
100
|
+
args: Bun.argv.slice(2),
|
|
101
|
+
options: {
|
|
102
|
+
add: { type: "boolean" },
|
|
103
|
+
resolve: { type: "boolean" },
|
|
104
|
+
list: { type: "boolean" },
|
|
105
|
+
title: { type: "string" },
|
|
106
|
+
context: { type: "string" },
|
|
107
|
+
id: { type: "string" },
|
|
108
|
+
all: { type: "boolean" },
|
|
109
|
+
help: { type: "boolean", short: "h" },
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const cmd = values.add
|
|
114
|
+
? "add"
|
|
115
|
+
: values.resolve
|
|
116
|
+
? "resolve"
|
|
117
|
+
: values.list
|
|
118
|
+
? "list"
|
|
119
|
+
: null;
|
|
120
|
+
|
|
121
|
+
if (values.help || !cmd) {
|
|
122
|
+
console.log(`
|
|
123
|
+
Thread — Manage open threads across sessions
|
|
124
|
+
|
|
125
|
+
Usage:
|
|
126
|
+
thread.ts --add --title "..." [--context "..."]
|
|
127
|
+
thread.ts --resolve --id <id>
|
|
128
|
+
thread.ts --list [--all]
|
|
129
|
+
`);
|
|
130
|
+
process.exit(cmd ? 0 : 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (cmd === "add") {
|
|
134
|
+
if (!values.title) {
|
|
135
|
+
console.error("--title required");
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
const thread = addThread(values.title, values.context ?? "");
|
|
139
|
+
console.log(
|
|
140
|
+
JSON.stringify(
|
|
141
|
+
{ success: true, id: thread.id, message: `Thread added: ${thread.title}` },
|
|
142
|
+
null,
|
|
143
|
+
2
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (cmd === "resolve") {
|
|
149
|
+
if (!values.id) {
|
|
150
|
+
console.error("--id required");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
console.log(JSON.stringify(resolveThread(values.id), null, 2));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (cmd === "list") {
|
|
157
|
+
const threads = listThreads(values.all ?? false);
|
|
158
|
+
console.log(JSON.stringify({ count: threads.length, threads }, null, 2));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (import.meta.main) run();
|