loopat 0.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/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- package/web/dist/logo.png +0 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat module: SQLite-backed channels + 1:1 DMs.
|
|
3
|
+
*
|
|
4
|
+
* Storage of record is chat.db (bun:sqlite) at LOOPAT_HOME/chat.db.
|
|
5
|
+
* AI never reads the DB; when a loop is spawned from a chat conv, the
|
|
6
|
+
* relevant messages are dumped to a per-loop jsonl snapshot at
|
|
7
|
+
* loops/<id>/context/chat/<convId>.jsonl (last 1024 messages).
|
|
8
|
+
*
|
|
9
|
+
* Permissions (v0):
|
|
10
|
+
* - any auth user reads any channel and posts to any channel
|
|
11
|
+
* - any auth user creates channels; only admin deletes
|
|
12
|
+
* - DMs are strictly 1:1, visible only to the two parties
|
|
13
|
+
*/
|
|
14
|
+
import { Database } from "bun:sqlite"
|
|
15
|
+
import { randomUUID } from "node:crypto"
|
|
16
|
+
import { mkdir, writeFile } from "node:fs/promises"
|
|
17
|
+
import { dirname } from "node:path"
|
|
18
|
+
import { chatDbPath } from "./paths"
|
|
19
|
+
|
|
20
|
+
export type ConvKind = "channel" | "dm"
|
|
21
|
+
|
|
22
|
+
export type Conversation = {
|
|
23
|
+
id: string
|
|
24
|
+
kind: ConvKind
|
|
25
|
+
name: string | null
|
|
26
|
+
topic: string | null
|
|
27
|
+
createdBy: string
|
|
28
|
+
createdAt: number
|
|
29
|
+
dmUserA: string | null
|
|
30
|
+
dmUserB: string | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type Message = {
|
|
34
|
+
id: number
|
|
35
|
+
convId: string
|
|
36
|
+
author: string
|
|
37
|
+
text: string
|
|
38
|
+
ts: number
|
|
39
|
+
/** NULL = thread root (a top-level message); otherwise the root msg id this
|
|
40
|
+
* reply belongs to. Slack-style single-level threading — replies cannot be
|
|
41
|
+
* replied to (we reject parent_id on a message that already has one). */
|
|
42
|
+
parentId: number | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Thread root surfaced in the main feed. Carries denormalized reply stats
|
|
46
|
+
* so the UI can render "💬 N replies" without a per-row roundtrip. */
|
|
47
|
+
export type ThreadRoot = Message & {
|
|
48
|
+
replyCount: number
|
|
49
|
+
lastReplyTs: number | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ConversationWithUnread = Conversation & {
|
|
53
|
+
unread: number
|
|
54
|
+
lastMessageTs: number | null
|
|
55
|
+
/** For DMs, the "display name" is the other party. */
|
|
56
|
+
peerUserId: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let _db: Database | null = null
|
|
60
|
+
|
|
61
|
+
function db(): Database {
|
|
62
|
+
if (_db) return _db
|
|
63
|
+
const path = chatDbPath()
|
|
64
|
+
// mkdir parent in case LOOPAT_HOME doesn't exist yet (bootstrap order).
|
|
65
|
+
// ensureWorkspaceDirs runs before chat is used, but be defensive.
|
|
66
|
+
const d = new Database(path, { create: true })
|
|
67
|
+
d.exec(`
|
|
68
|
+
PRAGMA journal_mode = WAL;
|
|
69
|
+
PRAGMA synchronous = NORMAL;
|
|
70
|
+
PRAGMA foreign_keys = ON;
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
kind TEXT NOT NULL CHECK (kind IN ('channel','dm')),
|
|
75
|
+
name TEXT,
|
|
76
|
+
topic TEXT,
|
|
77
|
+
created_by TEXT NOT NULL,
|
|
78
|
+
created_at INTEGER NOT NULL,
|
|
79
|
+
deleted_at INTEGER,
|
|
80
|
+
dm_user_a TEXT,
|
|
81
|
+
dm_user_b TEXT
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE UNIQUE INDEX IF NOT EXISTS conv_channel_name
|
|
85
|
+
ON conversations(name) WHERE kind = 'channel' AND deleted_at IS NULL;
|
|
86
|
+
CREATE UNIQUE INDEX IF NOT EXISTS conv_dm_pair
|
|
87
|
+
ON conversations(dm_user_a, dm_user_b) WHERE kind = 'dm';
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
90
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
91
|
+
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
92
|
+
author TEXT NOT NULL,
|
|
93
|
+
text TEXT NOT NULL,
|
|
94
|
+
ts INTEGER NOT NULL,
|
|
95
|
+
parent_id INTEGER REFERENCES messages(id) ON DELETE CASCADE
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS msg_conv_id_idx ON messages(conv_id, id);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS reads (
|
|
100
|
+
user_id TEXT NOT NULL,
|
|
101
|
+
conv_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
102
|
+
last_read_id INTEGER NOT NULL,
|
|
103
|
+
PRIMARY KEY (user_id, conv_id)
|
|
104
|
+
);
|
|
105
|
+
`)
|
|
106
|
+
// Migrate DBs created before parent_id existed. SQLite has no
|
|
107
|
+
// IF NOT EXISTS on ADD COLUMN, so swallow the duplicate-column error.
|
|
108
|
+
// MUST run before the partial index below — `WHERE parent_id IS NOT NULL`
|
|
109
|
+
// fails to parse if the column isn't there yet.
|
|
110
|
+
try {
|
|
111
|
+
d.exec(`ALTER TABLE messages ADD COLUMN parent_id INTEGER REFERENCES messages(id) ON DELETE CASCADE`)
|
|
112
|
+
} catch (e: any) {
|
|
113
|
+
if (!/duplicate column/i.test(e?.message ?? "")) throw e
|
|
114
|
+
}
|
|
115
|
+
d.exec(`CREATE INDEX IF NOT EXISTS msg_parent_idx ON messages(parent_id, id) WHERE parent_id IS NOT NULL`)
|
|
116
|
+
_db = d
|
|
117
|
+
return d
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function rowToConv(r: any): Conversation {
|
|
121
|
+
return {
|
|
122
|
+
id: r.id,
|
|
123
|
+
kind: r.kind,
|
|
124
|
+
name: r.name,
|
|
125
|
+
topic: r.topic,
|
|
126
|
+
createdBy: r.created_by,
|
|
127
|
+
createdAt: r.created_at,
|
|
128
|
+
dmUserA: r.dm_user_a,
|
|
129
|
+
dmUserB: r.dm_user_b,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rowToMessage(r: any): Message {
|
|
134
|
+
return {
|
|
135
|
+
id: r.id,
|
|
136
|
+
convId: r.conv_id,
|
|
137
|
+
author: r.author,
|
|
138
|
+
text: r.text,
|
|
139
|
+
ts: r.ts,
|
|
140
|
+
parentId: r.parent_id ?? null,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function isValidChannelName(name: string): boolean {
|
|
145
|
+
// lowercase letters, digits, dash, underscore. 1–32 chars. Slack-like.
|
|
146
|
+
return /^[a-z0-9][a-z0-9_-]{0,31}$/.test(name)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── channels ──────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export function listChannels(): Conversation[] {
|
|
152
|
+
const rows = db()
|
|
153
|
+
.query<any, []>(
|
|
154
|
+
`SELECT * FROM conversations
|
|
155
|
+
WHERE kind = 'channel' AND deleted_at IS NULL
|
|
156
|
+
ORDER BY name ASC`,
|
|
157
|
+
)
|
|
158
|
+
.all()
|
|
159
|
+
return rows.map(rowToConv)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createChannel(opts: { name: string; topic?: string; createdBy: string }): { ok: true; conv: Conversation } | { ok: false; error: string } {
|
|
163
|
+
const name = opts.name.trim().toLowerCase().replace(/^#/, "")
|
|
164
|
+
if (!isValidChannelName(name)) {
|
|
165
|
+
return { ok: false, error: "channel name must be lowercase letters/digits/-/_, 1-32 chars, start with letter/digit" }
|
|
166
|
+
}
|
|
167
|
+
const existing = db().query<any, [string]>(
|
|
168
|
+
`SELECT * FROM conversations WHERE kind = 'channel' AND name = ? AND deleted_at IS NULL`,
|
|
169
|
+
).get(name)
|
|
170
|
+
if (existing) return { ok: false, error: "channel exists" }
|
|
171
|
+
const id = `c-${randomUUID()}`
|
|
172
|
+
const now = Date.now()
|
|
173
|
+
db().run(
|
|
174
|
+
`INSERT INTO conversations (id, kind, name, topic, created_by, created_at)
|
|
175
|
+
VALUES (?, 'channel', ?, ?, ?, ?)`,
|
|
176
|
+
[id, name, opts.topic?.trim() || null, opts.createdBy, now],
|
|
177
|
+
)
|
|
178
|
+
return { ok: true, conv: rowToConv(db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id)) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function deleteChannel(id: string): boolean {
|
|
182
|
+
const r = db().run(
|
|
183
|
+
`UPDATE conversations SET deleted_at = ? WHERE id = ? AND kind = 'channel' AND deleted_at IS NULL`,
|
|
184
|
+
[Date.now(), id],
|
|
185
|
+
)
|
|
186
|
+
return r.changes > 0
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── DMs ───────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/** Normalize the (a, b) tuple so DB lookup is canonical regardless of caller order. */
|
|
192
|
+
function normalizeDmPair(a: string, b: string): [string, string] {
|
|
193
|
+
return a < b ? [a, b] : [b, a]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function getOrCreateDm(userA: string, userB: string, createdBy: string): Conversation {
|
|
197
|
+
if (userA === userB) throw new Error("cannot DM yourself")
|
|
198
|
+
const [a, b] = normalizeDmPair(userA, userB)
|
|
199
|
+
const existing = db()
|
|
200
|
+
.query<any, [string, string]>(
|
|
201
|
+
`SELECT * FROM conversations WHERE kind = 'dm' AND dm_user_a = ? AND dm_user_b = ?`,
|
|
202
|
+
)
|
|
203
|
+
.get(a, b)
|
|
204
|
+
if (existing) return rowToConv(existing)
|
|
205
|
+
const id = `d-${randomUUID()}`
|
|
206
|
+
const now = Date.now()
|
|
207
|
+
db().run(
|
|
208
|
+
`INSERT INTO conversations (id, kind, created_by, created_at, dm_user_a, dm_user_b)
|
|
209
|
+
VALUES (?, 'dm', ?, ?, ?, ?)`,
|
|
210
|
+
[id, createdBy, now, a, b],
|
|
211
|
+
)
|
|
212
|
+
return rowToConv(db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getConv(id: string): Conversation | null {
|
|
216
|
+
const r = db().query<any, [string]>(`SELECT * FROM conversations WHERE id = ?`).get(id)
|
|
217
|
+
return r ? rowToConv(r) : null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Permission check: can `userId` read/write this conversation? */
|
|
221
|
+
export function userCanAccess(conv: Conversation, userId: string): boolean {
|
|
222
|
+
if (conv.kind === "channel") return conv.createdAt > 0 // any auth user
|
|
223
|
+
return conv.dmUserA === userId || conv.dmUserB === userId
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** All conversations visible to `userId`: every channel + every DM they're in. */
|
|
227
|
+
export function listConversationsForUser(userId: string): ConversationWithUnread[] {
|
|
228
|
+
const rows = db()
|
|
229
|
+
.query<any, [string, string, string]>(
|
|
230
|
+
`SELECT c.*,
|
|
231
|
+
(SELECT MAX(ts) FROM messages WHERE conv_id = c.id) AS last_ts,
|
|
232
|
+
(SELECT MAX(id) FROM messages WHERE conv_id = c.id) AS last_msg_id,
|
|
233
|
+
COALESCE((SELECT last_read_id FROM reads WHERE user_id = ? AND conv_id = c.id), 0) AS last_read_id
|
|
234
|
+
FROM conversations c
|
|
235
|
+
WHERE c.deleted_at IS NULL
|
|
236
|
+
AND (
|
|
237
|
+
c.kind = 'channel'
|
|
238
|
+
OR c.dm_user_a = ?
|
|
239
|
+
OR c.dm_user_b = ?
|
|
240
|
+
)`,
|
|
241
|
+
)
|
|
242
|
+
.all(userId, userId, userId)
|
|
243
|
+
return rows.map((r) => {
|
|
244
|
+
const conv = rowToConv(r)
|
|
245
|
+
const lastId = r.last_msg_id ?? 0
|
|
246
|
+
const lastRead = r.last_read_id ?? 0
|
|
247
|
+
const unread = lastId > lastRead
|
|
248
|
+
? (db().query<{ n: number }, [string, number]>(
|
|
249
|
+
`SELECT COUNT(*) AS n FROM messages WHERE conv_id = ? AND id > ?`,
|
|
250
|
+
).get(conv.id, lastRead)?.n ?? 0)
|
|
251
|
+
: 0
|
|
252
|
+
let peer: string | null = null
|
|
253
|
+
if (conv.kind === "dm") {
|
|
254
|
+
peer = conv.dmUserA === userId ? conv.dmUserB : conv.dmUserA
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
...conv,
|
|
258
|
+
unread,
|
|
259
|
+
lastMessageTs: r.last_ts ?? null,
|
|
260
|
+
peerUserId: peer,
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── messages ──────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** Main-feed listing: thread roots only. Each row carries denormalized
|
|
268
|
+
* reply_count + last_reply_ts via subqueries so the UI can render the
|
|
269
|
+
* "💬 N replies" affordance without a second roundtrip. */
|
|
270
|
+
export function listMessages(convId: string, opts: { before?: number; limit?: number } = {}): ThreadRoot[] {
|
|
271
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500)
|
|
272
|
+
const before = opts.before
|
|
273
|
+
const select = `
|
|
274
|
+
SELECT m.*,
|
|
275
|
+
(SELECT COUNT(*) FROM messages r WHERE r.parent_id = m.id) AS reply_count,
|
|
276
|
+
(SELECT MAX(ts) FROM messages r WHERE r.parent_id = m.id) AS last_reply_ts
|
|
277
|
+
FROM messages m
|
|
278
|
+
WHERE m.conv_id = ? AND m.parent_id IS NULL`
|
|
279
|
+
let rows: any[]
|
|
280
|
+
if (before && before > 0) {
|
|
281
|
+
rows = db()
|
|
282
|
+
.query<any, [string, number, number]>(`${select} AND m.id < ? ORDER BY m.id DESC LIMIT ?`)
|
|
283
|
+
.all(convId, before, limit)
|
|
284
|
+
} else {
|
|
285
|
+
rows = db()
|
|
286
|
+
.query<any, [string, number]>(`${select} ORDER BY m.id DESC LIMIT ?`)
|
|
287
|
+
.all(convId, limit)
|
|
288
|
+
}
|
|
289
|
+
// Return chronological (oldest → newest) — convenient for UI append.
|
|
290
|
+
return rows.reverse().map((r) => ({
|
|
291
|
+
...rowToMessage(r),
|
|
292
|
+
replyCount: r.reply_count ?? 0,
|
|
293
|
+
lastReplyTs: r.last_reply_ts ?? null,
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Return a thread (root + all replies, chronological). null if the root id
|
|
298
|
+
* doesn't exist or is itself a reply (we don't surface "half threads"). */
|
|
299
|
+
export function listThread(rootId: number): { root: Message; replies: Message[] } | null {
|
|
300
|
+
const rootRow = db().query<any, [number]>(`SELECT * FROM messages WHERE id = ?`).get(rootId)
|
|
301
|
+
if (!rootRow || rootRow.parent_id != null) return null
|
|
302
|
+
const replyRows = db()
|
|
303
|
+
.query<any, [number]>(`SELECT * FROM messages WHERE parent_id = ? ORDER BY id ASC`)
|
|
304
|
+
.all(rootId)
|
|
305
|
+
return {
|
|
306
|
+
root: rowToMessage(rootRow),
|
|
307
|
+
replies: replyRows.map(rowToMessage),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Post a message. If parentId is set, it must reference a top-level message
|
|
312
|
+
* in the same conv (no nested threads, no cross-conv replies). Returns the
|
|
313
|
+
* new message. */
|
|
314
|
+
export function postMessage(convId: string, author: string, text: string, parentId: number | null = null): Message {
|
|
315
|
+
const trimmed = text.replace(/\r\n/g, "\n")
|
|
316
|
+
if (!trimmed.trim()) throw new Error("empty message")
|
|
317
|
+
if (parentId != null) {
|
|
318
|
+
const parent = db().query<any, [number]>(`SELECT conv_id, parent_id FROM messages WHERE id = ?`).get(parentId)
|
|
319
|
+
if (!parent) throw new Error("parent message not found")
|
|
320
|
+
if (parent.conv_id !== convId) throw new Error("parent message belongs to another conversation")
|
|
321
|
+
if (parent.parent_id != null) throw new Error("cannot reply to a reply (threads are single-level)")
|
|
322
|
+
}
|
|
323
|
+
const ts = Date.now()
|
|
324
|
+
const r = db().run(
|
|
325
|
+
`INSERT INTO messages (conv_id, author, text, ts, parent_id) VALUES (?, ?, ?, ?, ?)`,
|
|
326
|
+
[convId, author, trimmed, ts, parentId],
|
|
327
|
+
)
|
|
328
|
+
return {
|
|
329
|
+
id: Number(r.lastInsertRowid),
|
|
330
|
+
convId,
|
|
331
|
+
author,
|
|
332
|
+
text: trimmed,
|
|
333
|
+
ts,
|
|
334
|
+
parentId,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function markRead(userId: string, convId: string, lastReadId: number): void {
|
|
339
|
+
db().run(
|
|
340
|
+
`INSERT INTO reads (user_id, conv_id, last_read_id) VALUES (?, ?, ?)
|
|
341
|
+
ON CONFLICT(user_id, conv_id) DO UPDATE SET last_read_id = MAX(last_read_id, excluded.last_read_id)`,
|
|
342
|
+
[userId, convId, lastReadId],
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── jsonl snapshot (for spawn-loop-from-thread) ───────────────────────────
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Dump a thread (root + all replies) to a jsonl file, chronological order:
|
|
350
|
+
* {"ts":"2026-05-16T10:42:00.000Z","author":"simpx","text":"..."}
|
|
351
|
+
*
|
|
352
|
+
* A "thread" is the natural semantic unit for AI seeding — every top-level
|
|
353
|
+
* message is a thread of length ≥ 1, so this works whether or not anyone
|
|
354
|
+
* actually replied. Returns null if the root doesn't exist or is itself a
|
|
355
|
+
* reply.
|
|
356
|
+
*/
|
|
357
|
+
export async function snapshotThreadToJsonl(
|
|
358
|
+
rootId: number,
|
|
359
|
+
destPath: string,
|
|
360
|
+
): Promise<{ messageCount: number; convId: string } | null> {
|
|
361
|
+
const t = listThread(rootId)
|
|
362
|
+
if (!t) return null
|
|
363
|
+
const all = [t.root, ...t.replies]
|
|
364
|
+
const lines = all.map((m) =>
|
|
365
|
+
JSON.stringify({
|
|
366
|
+
ts: new Date(m.ts).toISOString(),
|
|
367
|
+
author: m.author,
|
|
368
|
+
text: m.text,
|
|
369
|
+
}),
|
|
370
|
+
)
|
|
371
|
+
await mkdir(dirname(destPath), { recursive: true })
|
|
372
|
+
await writeFile(destPath, lines.join("\n") + (lines.length ? "\n" : ""))
|
|
373
|
+
return { messageCount: all.length, convId: t.root.convId }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── bootstrap ─────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
/** Run once on server startup. Opens the DB (creating schema) and seeds a
|
|
379
|
+
* default `#general` channel if no channels exist. */
|
|
380
|
+
export function initChat(bootstrapUser: string): void {
|
|
381
|
+
db() // open + migrate
|
|
382
|
+
const count = db()
|
|
383
|
+
.query<{ n: number }, []>(
|
|
384
|
+
`SELECT COUNT(*) AS n FROM conversations WHERE kind = 'channel' AND deleted_at IS NULL`,
|
|
385
|
+
)
|
|
386
|
+
.get()?.n ?? 0
|
|
387
|
+
if (count === 0 && bootstrapUser) {
|
|
388
|
+
createChannel({ name: "general", topic: "workspace-wide chatter", createdBy: bootstrapUser })
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { execSync } from "node:child_process"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import { dirname, resolve, join } from "node:path"
|
|
5
|
+
|
|
6
|
+
function detectIsMusl(): boolean {
|
|
7
|
+
if (process.platform !== "linux") return false
|
|
8
|
+
try {
|
|
9
|
+
const lddOut = execSync("ldd --version 2>&1", { encoding: "utf8" }) as string
|
|
10
|
+
return /musl/i.test(lddOut)
|
|
11
|
+
} catch {}
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function findWorkspaceRoot(start: string): string[] {
|
|
16
|
+
const roots: string[] = []
|
|
17
|
+
let cur = start
|
|
18
|
+
for (let i = 0; i < 10; i++) {
|
|
19
|
+
if (existsSync(join(cur, "node_modules"))) roots.push(cur)
|
|
20
|
+
const parent = dirname(cur)
|
|
21
|
+
if (parent === cur) break
|
|
22
|
+
cur = parent
|
|
23
|
+
}
|
|
24
|
+
if (roots.length === 0) throw new Error("could not locate node_modules from " + start)
|
|
25
|
+
return roots
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveClaudeBinary(): string {
|
|
29
|
+
const platform = process.platform
|
|
30
|
+
const arch = process.arch
|
|
31
|
+
const ext = platform === "win32" ? ".exe" : ""
|
|
32
|
+
|
|
33
|
+
const pkgs: string[] = []
|
|
34
|
+
if (platform === "linux") {
|
|
35
|
+
if (detectIsMusl()) {
|
|
36
|
+
pkgs.push(`claude-agent-sdk-linux-${arch}-musl`, `claude-agent-sdk-linux-${arch}`)
|
|
37
|
+
} else {
|
|
38
|
+
pkgs.push(`claude-agent-sdk-linux-${arch}`, `claude-agent-sdk-linux-${arch}-musl`)
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
pkgs.push(`claude-agent-sdk-${platform}-${arch}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const here = fileURLToPath(import.meta.url)
|
|
45
|
+
const roots = findWorkspaceRoot(dirname(here))
|
|
46
|
+
const candidates: string[] = []
|
|
47
|
+
for (const root of roots) {
|
|
48
|
+
for (const pkg of pkgs) {
|
|
49
|
+
candidates.push(join(root, "node_modules", "@anthropic-ai", pkg, `claude${ext}`))
|
|
50
|
+
const bunDir = join(root, "node_modules", ".bun")
|
|
51
|
+
if (existsSync(bunDir)) {
|
|
52
|
+
try {
|
|
53
|
+
const entries = execSync(`ls "${bunDir}"`, { encoding: "utf8" }).split("\n").filter(Boolean)
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
if (entry.startsWith(`@anthropic-ai+${pkg}@`)) {
|
|
56
|
+
candidates.push(join(bunDir, entry, "node_modules", "@anthropic-ai", pkg, `claude${ext}`))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const c of candidates) {
|
|
65
|
+
if (existsSync(c)) return c
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`claude binary not found; tried:\n${candidates.join("\n")}`)
|
|
68
|
+
}
|