heyquilt 0.0.1 → 0.3.90
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 +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Reads Claude Code conversation transcripts for one project folder and turns
|
|
2
|
+
// them into feed entries. Transcripts are JSONL files under
|
|
3
|
+
// ~/.claude/projects/<slug>/<sessionId>.jsonl, appended to as the chat goes on.
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { describeAction } from './actions.js'
|
|
8
|
+
import { capText } from './common.js'
|
|
9
|
+
|
|
10
|
+
const POLL_MS = 750
|
|
11
|
+
const IDLE_AFTER_MS = 2 * 60 * 1000
|
|
12
|
+
const BACKFILL_MS = 60 * 60 * 1000
|
|
13
|
+
const BACKFILL_ENTRIES = 50
|
|
14
|
+
const TITLE_CHARS = 80
|
|
15
|
+
|
|
16
|
+
export const slugFor = (dir) => path.resolve(dir).replace(/[^A-Za-z0-9]/g, '-')
|
|
17
|
+
|
|
18
|
+
export function claudeProjectsDir (home = os.homedir()) {
|
|
19
|
+
const base = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude')
|
|
20
|
+
return path.join(base, 'projects')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} o
|
|
25
|
+
* @param {string} o.dir synced folder
|
|
26
|
+
* @param {(entries: object[]) => void} o.onEntries
|
|
27
|
+
* @param {(state: object) => void} o.onState
|
|
28
|
+
* @param {(line: string) => void} [o.onLog]
|
|
29
|
+
* @param {string} [o.home]
|
|
30
|
+
* @param {string} [o.chatDir] folder the AI works from, if not `dir` (an agent that synced into a subfolder)
|
|
31
|
+
*/
|
|
32
|
+
export function startClaudeCodeReader ({ dir, chatDir, onEntries, onState, onLog = () => {}, home, pollMs = POLL_MS, now = Date.now }) {
|
|
33
|
+
dir = path.resolve(dir)
|
|
34
|
+
const chatRoot = path.resolve(chatDir || dir)
|
|
35
|
+
const root = claudeProjectsDir(home)
|
|
36
|
+
const slug = slugFor(chatRoot)
|
|
37
|
+
const files = new Map() // path -> { offset, partial, seen: Set<path> }
|
|
38
|
+
let status = null
|
|
39
|
+
let lastLineAt = 0
|
|
40
|
+
let stopped = false
|
|
41
|
+
let failed = false
|
|
42
|
+
let first = true
|
|
43
|
+
|
|
44
|
+
const setStatus = (s) => {
|
|
45
|
+
if (s === status) return
|
|
46
|
+
status = s
|
|
47
|
+
onState({ tool: 'Claude Code', status: s })
|
|
48
|
+
}
|
|
49
|
+
setStatus('idle')
|
|
50
|
+
|
|
51
|
+
const inside = (cwd) => typeof cwd === 'string' && (cwd === chatRoot || cwd.startsWith(chatRoot + path.sep))
|
|
52
|
+
|
|
53
|
+
function parseLine (line, file, st) {
|
|
54
|
+
let j
|
|
55
|
+
try { j = JSON.parse(line) } catch { return null }
|
|
56
|
+
// The conversation's title, as the person sees it in Claude Code's list (its own name for the
|
|
57
|
+
// chat, or one they gave it). These lines carry no folder, so they're read before the check.
|
|
58
|
+
// Entries from here on carry it, so the Loom and the feed can name the conversation the same way.
|
|
59
|
+
if (j && (j.type === 'custom-title' || j.type === 'agent-name')) {
|
|
60
|
+
const t = String(j.customTitle || j.agentName || '').replace(/\s+/g, ' ').trim().slice(0, TITLE_CHARS)
|
|
61
|
+
if (t && (j.type === 'custom-title' || !st.title)) st.title = t
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
if (!j || j.isSidechain || !inside(j.cwd)) return null
|
|
65
|
+
const ts = Date.parse(j.timestamp) || now()
|
|
66
|
+
const conv = j.sessionId || path.basename(file, '.jsonl')
|
|
67
|
+
const out = []
|
|
68
|
+
const msg = j.message || {}
|
|
69
|
+
|
|
70
|
+
if (j.type === 'user' && !j.isMeta) {
|
|
71
|
+
const text = typeof msg.content === 'string'
|
|
72
|
+
? msg.content
|
|
73
|
+
: Array.isArray(msg.content) ? msg.content.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n\n') : ''
|
|
74
|
+
if (text.trim() && !isSystemText(text)) {
|
|
75
|
+
out.push({ id: j.uuid, tool: 'Claude Code', conv, kind: 'prompt', text: capText(text.trim()), ts })
|
|
76
|
+
}
|
|
77
|
+
} else if (j.type === 'assistant' && Array.isArray(msg.content)) {
|
|
78
|
+
msg.content.forEach((b, i) => {
|
|
79
|
+
if (!b) return
|
|
80
|
+
const id = `${j.uuid}:${i}`
|
|
81
|
+
if (b.type === 'text' && b.text && b.text.trim()) {
|
|
82
|
+
out.push({ id, tool: 'Claude Code', conv, kind: 'reply', text: capText(b.text.trim()), ts })
|
|
83
|
+
} else if (b.type === 'tool_use') {
|
|
84
|
+
const target = b.input && (b.input.file_path || b.input.notebook_path)
|
|
85
|
+
const abs = target ? path.resolve(dir, target) : null
|
|
86
|
+
const text = describeAction(b.name, b.input, dir, { existed: (p) => st.seen.has(p) })
|
|
87
|
+
if (abs) st.seen.add(abs)
|
|
88
|
+
out.push({ id, tool: 'Claude Code', conv, kind: 'action', text, ts })
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
if (st.title) for (const e of out) e.title = st.title
|
|
93
|
+
return { entries: out, endTurn: j.type === 'assistant' && msg.stop_reason === 'end_turn', relevant: j.type === 'user' || j.type === 'assistant' }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readFile (file, backfill) {
|
|
97
|
+
let st = files.get(file)
|
|
98
|
+
const size = fs.statSync(file).size
|
|
99
|
+
if (!st) {
|
|
100
|
+
st = { offset: 0, partial: '', seen: new Set() }
|
|
101
|
+
files.set(file, st)
|
|
102
|
+
if (!backfill) {
|
|
103
|
+
// Older conversation: only watch it from here on.
|
|
104
|
+
st.offset = size
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (size < st.offset) { st.offset = 0; st.partial = '' } // truncated or replaced
|
|
109
|
+
if (size === st.offset) return []
|
|
110
|
+
const fd = fs.openSync(file, 'r')
|
|
111
|
+
let chunk
|
|
112
|
+
try {
|
|
113
|
+
const buf = Buffer.alloc(size - st.offset)
|
|
114
|
+
fs.readSync(fd, buf, 0, buf.length, st.offset)
|
|
115
|
+
chunk = buf.toString('utf8')
|
|
116
|
+
} finally {
|
|
117
|
+
fs.closeSync(fd)
|
|
118
|
+
}
|
|
119
|
+
st.offset = size
|
|
120
|
+
const text = st.partial + chunk
|
|
121
|
+
const lines = text.split('\n')
|
|
122
|
+
st.partial = lines.pop() // incomplete last line (no newline yet)
|
|
123
|
+
const entries = []
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
if (!line.trim()) continue
|
|
126
|
+
const r = parseLine(line, file, st)
|
|
127
|
+
if (!r) continue
|
|
128
|
+
entries.push(...r.entries)
|
|
129
|
+
if (r.relevant) {
|
|
130
|
+
lastLineAt = now()
|
|
131
|
+
if (!backfill) setStatus(r.endTurn ? 'idle' : 'working')
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function tick () {
|
|
138
|
+
if (stopped) return
|
|
139
|
+
try {
|
|
140
|
+
let dirs = []
|
|
141
|
+
try {
|
|
142
|
+
dirs = fs.readdirSync(root, { withFileTypes: true })
|
|
143
|
+
.filter((d) => d.isDirectory() && d.name.startsWith(slug))
|
|
144
|
+
.map((d) => path.join(root, d.name))
|
|
145
|
+
} catch {} // Claude Code not used yet here
|
|
146
|
+
const out = []
|
|
147
|
+
for (const d of dirs) {
|
|
148
|
+
let names = []
|
|
149
|
+
try { names = fs.readdirSync(d).filter((n) => n.endsWith('.jsonl')) } catch { continue }
|
|
150
|
+
for (const n of names) {
|
|
151
|
+
const file = path.join(d, n)
|
|
152
|
+
const known = files.has(file)
|
|
153
|
+
let backfill = false
|
|
154
|
+
if (!known) {
|
|
155
|
+
// On startup, pick up recent conversations; after that, any new file is live.
|
|
156
|
+
backfill = !first || now() - fs.statSync(file).mtimeMs < BACKFILL_MS
|
|
157
|
+
}
|
|
158
|
+
try { out.push(...readFile(file, backfill)) } catch (err) {
|
|
159
|
+
if (!failed) { failed = true; onLog(`Claude Code feed: could not read ${n}: ${err.message}`) }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
first = false
|
|
164
|
+
if (status === 'working' && now() - lastLineAt > IDLE_AFTER_MS) setStatus('idle')
|
|
165
|
+
if (out.length) onEntries(out.sort((a, b) => a.ts - b.ts))
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (!failed) { failed = true; onLog(`Claude Code feed error: ${err.message}`) }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
tick()
|
|
172
|
+
const timer = setInterval(tick, pollMs)
|
|
173
|
+
return { stop () { stopped = true; clearInterval(timer) }, tick }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Slash commands, hook output and similar plumbing that show up as "user" lines.
|
|
177
|
+
function isSystemText (text) {
|
|
178
|
+
return /^\s*<(command-name|command-message|command-args|local-command-stdout|local-command-stderr|system-reminder|user-memory-input|bash-input|bash-stdout|bash-stderr|task-notification)\b/.test(text) ||
|
|
179
|
+
text.startsWith('Caveat: The messages below were generated by the user while running local commands')
|
|
180
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const MAX_ENTRY_CHARS = 8000
|
|
2
|
+
|
|
3
|
+
export function capText (text) {
|
|
4
|
+
text = String(text ?? '')
|
|
5
|
+
return text.length > MAX_ENTRY_CHARS ? text.slice(0, MAX_ENTRY_CHARS) + '…(truncated)' : text
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** "claude-code" -> "Claude Code" etc., from the MCP client's name or an agent provider. */
|
|
9
|
+
export function toolLabel (client) {
|
|
10
|
+
const n = String(client || '')
|
|
11
|
+
if (/claude/i.test(n)) return 'Claude Code'
|
|
12
|
+
if (/cursor/i.test(n)) return 'Cursor'
|
|
13
|
+
if (/codex/i.test(n)) return 'Codex'
|
|
14
|
+
if (/windsurf|codeium/i.test(n)) return 'Windsurf'
|
|
15
|
+
if (/zed/i.test(n)) return 'Zed'
|
|
16
|
+
// Model providers (agents often register these, not the host IDE).
|
|
17
|
+
if (/\bxai\b|grok/i.test(n)) return 'xAI'
|
|
18
|
+
if (/chatgpt|openai/i.test(n)) return 'Codex'
|
|
19
|
+
if (/gemini/i.test(n)) return 'Other'
|
|
20
|
+
return n || 'AI agent'
|
|
21
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Reads Cursor agent transcripts for one project folder. Current Cursor stores
|
|
2
|
+
// the agent chat as JSONL under ~/.cursor/projects/<slug>/agent-transcripts/,
|
|
3
|
+
// not in the composer database the older reader polls. The slug is Cursor's:
|
|
4
|
+
// any character that is not a letter or digit becomes a hyphen.
|
|
5
|
+
import fs from 'node:fs'
|
|
6
|
+
import os from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { describeAction } from './actions.js'
|
|
9
|
+
import { capText } from './common.js'
|
|
10
|
+
|
|
11
|
+
const POLL_MS = 750
|
|
12
|
+
const IDLE_AFTER_MS = 2 * 60 * 1000
|
|
13
|
+
const BACKFILL_MS = 60 * 60 * 1000
|
|
14
|
+
const BACKFILL_ENTRIES = 50
|
|
15
|
+
|
|
16
|
+
export function cursorProjectsDir (home = os.homedir()) {
|
|
17
|
+
return path.join(home, '.cursor', 'projects')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cursor's ~/.cursor/projects folder name for an absolute path.
|
|
21
|
+
* Every character that is not a letter or digit becomes a hyphen, runs of
|
|
22
|
+
* hyphens collapse, and hyphens at either end are removed.
|
|
23
|
+
* `C:\Quilt Test (1)` -> `C-Quilt-Test-1`, `/Users/a/my app` -> `Users-a-my-app`. */
|
|
24
|
+
export function slugCursorPath (absPath) {
|
|
25
|
+
return String(absPath).replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function cursorProjectSlug (dir) {
|
|
29
|
+
return slugCursorPath(path.resolve(dir))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function projectMatches (name, dir) {
|
|
33
|
+
const slug = cursorProjectSlug(dir)
|
|
34
|
+
if (!slug) return false
|
|
35
|
+
return process.platform === 'win32' ? name.toLowerCase() === slug.toLowerCase() : name === slug
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Cursor wraps the typed message in timestamp and user_query tags. Share only the message. */
|
|
39
|
+
export function cursorPromptText (text) {
|
|
40
|
+
const raw = String(text || '')
|
|
41
|
+
const queries = [...raw.matchAll(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/gi)].map((m) => m[1].trim()).filter(Boolean)
|
|
42
|
+
if (queries.length) return queries.join('\n\n')
|
|
43
|
+
return raw.replace(/<timestamp>[\s\S]*?<\/timestamp>/gi, '').trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function startCursorTranscriptReader ({ dir, onEntries, onState, onLog = () => {}, projectsDir, pollMs = POLL_MS, now = Date.now }) {
|
|
47
|
+
dir = path.resolve(dir)
|
|
48
|
+
const root = projectsDir || cursorProjectsDir()
|
|
49
|
+
const files = new Map() // path -> { offset, partial, lineNo }
|
|
50
|
+
let status = null
|
|
51
|
+
let lastLineAt = 0
|
|
52
|
+
let stopped = false
|
|
53
|
+
let failed = false
|
|
54
|
+
let first = true
|
|
55
|
+
|
|
56
|
+
const setStatus = (s) => {
|
|
57
|
+
if (s === status) return
|
|
58
|
+
status = s
|
|
59
|
+
onState({ tool: 'Cursor', status: s })
|
|
60
|
+
}
|
|
61
|
+
setStatus('idle')
|
|
62
|
+
|
|
63
|
+
function parseLine (line, conv, lineNo) {
|
|
64
|
+
let j
|
|
65
|
+
try { j = JSON.parse(line) } catch { return null }
|
|
66
|
+
const ts = now() + lineNo
|
|
67
|
+
const content = j.message && Array.isArray(j.message.content) ? j.message.content : []
|
|
68
|
+
const out = []
|
|
69
|
+
if (j.role === 'user') {
|
|
70
|
+
const text = cursorPromptText(content.filter((b) => b && b.type === 'text' && b.text).map((b) => b.text).join('\n\n'))
|
|
71
|
+
if (text.trim()) out.push({ id: `${conv}:${lineNo}`, tool: 'Cursor', conv, kind: 'prompt', text: capText(text.trim()), ts })
|
|
72
|
+
} else if (j.role === 'assistant') {
|
|
73
|
+
content.forEach((b, i) => {
|
|
74
|
+
if (!b || b.type === 'thinking') return
|
|
75
|
+
const id = `${conv}:${lineNo}:${i}`
|
|
76
|
+
if (b.type === 'text' && b.text && b.text.trim()) out.push({ id, tool: 'Cursor', conv, kind: 'reply', text: capText(b.text.trim()), ts })
|
|
77
|
+
else if (b.type === 'tool_use' && b.name) out.push({ id, tool: 'Cursor', conv, kind: 'action', text: describeAction(b.name, b.input, dir), ts })
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
return { entries: out, endTurn: j.type === 'turn_ended', relevant: j.role === 'user' || j.role === 'assistant' || j.type === 'turn_ended' }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readFile (file, backfill) {
|
|
84
|
+
let st = files.get(file)
|
|
85
|
+
const size = fs.statSync(file).size
|
|
86
|
+
if (!st) {
|
|
87
|
+
st = { offset: 0, partial: '', lineNo: 0 }
|
|
88
|
+
files.set(file, st)
|
|
89
|
+
if (!backfill) { st.offset = size; return [] }
|
|
90
|
+
}
|
|
91
|
+
if (size < st.offset) { st.offset = 0; st.partial = ''; st.lineNo = 0 }
|
|
92
|
+
if (size === st.offset) return []
|
|
93
|
+
const fd = fs.openSync(file, 'r')
|
|
94
|
+
let chunk
|
|
95
|
+
try {
|
|
96
|
+
const buf = Buffer.alloc(size - st.offset)
|
|
97
|
+
fs.readSync(fd, buf, 0, buf.length, st.offset)
|
|
98
|
+
chunk = buf.toString('utf8')
|
|
99
|
+
} finally {
|
|
100
|
+
fs.closeSync(fd)
|
|
101
|
+
}
|
|
102
|
+
st.offset = size
|
|
103
|
+
const text = st.partial + chunk
|
|
104
|
+
const lines = text.split('\n')
|
|
105
|
+
st.partial = lines.pop()
|
|
106
|
+
const conv = path.basename(file, '.jsonl')
|
|
107
|
+
const entries = []
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
if (!line.trim()) continue
|
|
110
|
+
st.lineNo++
|
|
111
|
+
const r = parseLine(line, conv, st.lineNo)
|
|
112
|
+
if (!r) continue
|
|
113
|
+
entries.push(...r.entries)
|
|
114
|
+
if (r.relevant) {
|
|
115
|
+
lastLineAt = now()
|
|
116
|
+
setStatus(r.endTurn ? 'idle' : 'working')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return backfill ? entries.slice(-BACKFILL_ENTRIES) : entries
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function transcripts (projectDir) {
|
|
123
|
+
const base = path.join(projectDir, 'agent-transcripts')
|
|
124
|
+
const out = []
|
|
125
|
+
const walk = (d, depth) => {
|
|
126
|
+
if (depth > 3) return
|
|
127
|
+
let entries = []
|
|
128
|
+
try { entries = fs.readdirSync(d, { withFileTypes: true }) } catch { return }
|
|
129
|
+
for (const e of entries) {
|
|
130
|
+
const p = path.join(d, e.name)
|
|
131
|
+
if (e.isDirectory()) walk(p, depth + 1)
|
|
132
|
+
else if (e.name.endsWith('.jsonl')) out.push(p)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
walk(base, 0)
|
|
136
|
+
return out
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function tick () {
|
|
140
|
+
if (stopped) return
|
|
141
|
+
try {
|
|
142
|
+
let dirs = []
|
|
143
|
+
try {
|
|
144
|
+
dirs = fs.readdirSync(root, { withFileTypes: true })
|
|
145
|
+
.filter((d) => d.isDirectory() && projectMatches(d.name, dir))
|
|
146
|
+
.map((d) => path.join(root, d.name))
|
|
147
|
+
} catch {}
|
|
148
|
+
const out = []
|
|
149
|
+
for (const d of dirs) {
|
|
150
|
+
for (const file of transcripts(d)) {
|
|
151
|
+
const known = files.has(file)
|
|
152
|
+
let backfill = false
|
|
153
|
+
if (!known) backfill = !first || now() - fs.statSync(file).mtimeMs < BACKFILL_MS
|
|
154
|
+
try { out.push(...readFile(file, backfill)) } catch (err) {
|
|
155
|
+
if (!failed) { failed = true; onLog(`Cursor feed: could not read ${path.basename(file)}: ${err.message}`) }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
first = false
|
|
160
|
+
if (status === 'working' && now() - lastLineAt > IDLE_AFTER_MS) setStatus('idle')
|
|
161
|
+
if (out.length) onEntries(out)
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (!failed) { failed = true; onLog(`Cursor feed error: ${err.message}`) }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
tick()
|
|
168
|
+
const timer = setInterval(tick, pollMs)
|
|
169
|
+
return { stop () { stopped = true; clearInterval(timer) } }
|
|
170
|
+
}
|