musecode 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/.env.example +31 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/codex-mcp.mjs +64 -0
- package/codex-sessions.mjs +153 -0
- package/config.example.json +19 -0
- package/desktop-mcp.mjs +64 -0
- package/lib.mjs +225 -0
- package/live.mjs +95 -0
- package/package.json +30 -0
- package/run-server.ps1 +9 -0
- package/server.mjs +1131 -0
- package/sessions.mjs +102 -0
- package/start.ps1 +45 -0
- package/threads.mjs +59 -0
package/sessions.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Read-only access to the user's existing Claude Code / Claude Desktop sessions in ~/.claude/projects.
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const PROJECTS = path.join(os.homedir(), ".claude", "projects");
|
|
7
|
+
// Internal sessions from tools like claude-mem, not conversations the user had.
|
|
8
|
+
const SKIP_DIRS = [/claude-mem-observer/i];
|
|
9
|
+
// A session file written to this recently is probably open in Desktop right now.
|
|
10
|
+
const ACTIVE_MS = 3 * 60_000;
|
|
11
|
+
|
|
12
|
+
const cache = new Map(); // file -> { mtimeMs, info }
|
|
13
|
+
|
|
14
|
+
function parseLines(file) {
|
|
15
|
+
return fs.readFileSync(file, "utf8").split("\n").flatMap((l) => {
|
|
16
|
+
if (!l) return [];
|
|
17
|
+
try { return [JSON.parse(l)]; } catch { return []; }
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function textOf(content) {
|
|
22
|
+
if (typeof content === "string") return content;
|
|
23
|
+
if (!Array.isArray(content)) return "";
|
|
24
|
+
return content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function summarize(file, stat) {
|
|
28
|
+
let cwd = null, customTitle = null, aiTitle = null, firstPrompt = null, lastPrompt = null, messages = 0, permissionMode = null;
|
|
29
|
+
for (const e of parseLines(file)) {
|
|
30
|
+
if (!cwd && e.cwd) cwd = e.cwd;
|
|
31
|
+
// Ignore modes recorded by the bridge's own headless runs (sdk-cli); we want the mode the session really runs in.
|
|
32
|
+
if (e.permissionMode && e.entrypoint !== "sdk-cli") permissionMode = e.permissionMode;
|
|
33
|
+
if (e.type === "custom-title") customTitle = e.customTitle;
|
|
34
|
+
else if (e.type === "ai-title") aiTitle = e.aiTitle;
|
|
35
|
+
else if (e.type === "last-prompt") lastPrompt = e.lastPrompt;
|
|
36
|
+
else if ((e.type === "user" || e.type === "assistant") && !e.isSidechain) {
|
|
37
|
+
const text = textOf(e.message?.content);
|
|
38
|
+
if (!text) continue;
|
|
39
|
+
messages++;
|
|
40
|
+
if (!firstPrompt && e.type === "user") firstPrompt = text;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!cwd || !messages) return null;
|
|
44
|
+
const clip = (s) => (s ? s.replace(/\s+/g, " ").trim().slice(0, 140) : null);
|
|
45
|
+
return {
|
|
46
|
+
session_id: path.basename(file, ".jsonl"),
|
|
47
|
+
desktop_title: customTitle || aiTitle || null,
|
|
48
|
+
title: clip(customTitle || aiTitle || firstPrompt),
|
|
49
|
+
project_folder: cwd,
|
|
50
|
+
last_prompt: clip(lastPrompt),
|
|
51
|
+
permission_mode: permissionMode,
|
|
52
|
+
messages,
|
|
53
|
+
last_active: new Date(stat.mtimeMs).toISOString(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sessionFiles() {
|
|
58
|
+
if (!fs.existsSync(PROJECTS)) return [];
|
|
59
|
+
return fs.readdirSync(PROJECTS, { withFileTypes: true })
|
|
60
|
+
.filter((d) => d.isDirectory() && !SKIP_DIRS.some((re) => re.test(d.name)))
|
|
61
|
+
.flatMap((d) => fs.readdirSync(path.join(PROJECTS, d.name))
|
|
62
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
63
|
+
.map((f) => path.join(PROJECTS, d.name, f)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function infoFor(file) {
|
|
67
|
+
const stat = fs.statSync(file);
|
|
68
|
+
const hit = cache.get(file);
|
|
69
|
+
if (!hit || hit.mtimeMs !== stat.mtimeMs) cache.set(file, { mtimeMs: stat.mtimeMs, info: summarize(file, stat) });
|
|
70
|
+
const info = cache.get(file).info;
|
|
71
|
+
return info && { ...info, open_now: Date.now() - stat.mtimeMs < ACTIVE_MS };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function listSessions({ query, limit = 20, hide = new Set() } = {}) {
|
|
75
|
+
const q = query?.toLowerCase();
|
|
76
|
+
return sessionFiles().map(infoFor).filter((s) => s && !hide.has(s.session_id))
|
|
77
|
+
.filter((s) => !q || [s.title, s.project_folder, s.last_prompt].some((v) => v?.toLowerCase().includes(q)))
|
|
78
|
+
.sort((a, b) => b.last_active.localeCompare(a.last_active))
|
|
79
|
+
.slice(0, Math.min(limit, 100));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function fileFor(sessionId) {
|
|
83
|
+
if (!/^[0-9a-f-]{36}$/i.test(sessionId || "")) return null;
|
|
84
|
+
return sessionFiles().find((f) => path.basename(f, ".jsonl") === sessionId) || null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getSession(sessionId) {
|
|
88
|
+
const file = fileFor(sessionId);
|
|
89
|
+
return file ? infoFor(file) : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Recent user/assistant text turns (tool calls and tool output left out).
|
|
93
|
+
export function readSession(sessionId, last = 20) {
|
|
94
|
+
const file = fileFor(sessionId);
|
|
95
|
+
if (!file) return null;
|
|
96
|
+
const turns = parseLines(file)
|
|
97
|
+
.filter((e) => (e.type === "user" || e.type === "assistant") && !e.isSidechain)
|
|
98
|
+
.map((e) => ({ role: e.type, at: e.timestamp, text: textOf(e.message?.content).trim() }))
|
|
99
|
+
.filter((t) => t.text && !t.text.startsWith("<"))
|
|
100
|
+
.map((t) => ({ ...t, text: t.text.length > 3000 ? t.text.slice(0, 3000) + " …[truncated]" : t.text }));
|
|
101
|
+
return { ...infoFor(file), turns: turns.slice(-Math.min(Math.max(last, 1), 100)) };
|
|
102
|
+
}
|
package/start.ps1
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Starts a Cloudflare quick tunnel, then the bridge pinned to that tunnel's URL.
|
|
2
|
+
# Ctrl+C stops both. The URL changes every run, so update the remote client after restarting.
|
|
3
|
+
$ErrorActionPreference = "Stop"
|
|
4
|
+
Set-Location $PSScriptRoot
|
|
5
|
+
New-Item -ItemType Directory -Force data | Out-Null
|
|
6
|
+
|
|
7
|
+
$cloudflared = (Get-Command cloudflared -ErrorAction SilentlyContinue).Source
|
|
8
|
+
if (-not $cloudflared) { $cloudflared = "C:\Program Files (x86)\cloudflared\cloudflared.exe" }
|
|
9
|
+
if (-not (Test-Path $cloudflared)) { Write-Host "cloudflared not found. Install: winget install --id Cloudflare.cloudflared"; exit 1 }
|
|
10
|
+
|
|
11
|
+
$port = node --input-type=module -e "import('./lib.mjs').then(m => process.stdout.write(String(m.loadConfig().port)))"
|
|
12
|
+
if (-not $port) { Write-Host "Could not load the bridge port from .env, config.json, or defaults." -ForegroundColor Red; exit 1 }
|
|
13
|
+
|
|
14
|
+
$tunnelLog = "data\tunnel.log"
|
|
15
|
+
Remove-Item $tunnelLog -ErrorAction SilentlyContinue
|
|
16
|
+
$tunnel = Start-Process $cloudflared -ArgumentList "tunnel --no-autoupdate --url http://127.0.0.1:$port" `
|
|
17
|
+
-WindowStyle Hidden -PassThru -RedirectStandardError $tunnelLog -RedirectStandardOutput "data\tunnel.out.log"
|
|
18
|
+
|
|
19
|
+
$url = $null
|
|
20
|
+
for ($i = 0; $i -lt 45 -and -not $url; $i++) {
|
|
21
|
+
Start-Sleep -Seconds 1
|
|
22
|
+
$m = Select-String -Path $tunnelLog -Pattern "https://[a-z0-9-]+\.trycloudflare\.com" -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
23
|
+
if ($m) { $url = $m.Matches[0].Value }
|
|
24
|
+
}
|
|
25
|
+
if (-not $url) {
|
|
26
|
+
Write-Host "Tunnel did not come up; see $tunnelLog" -ForegroundColor Red
|
|
27
|
+
Stop-Process -Id $tunnel.Id -ErrorAction SilentlyContinue
|
|
28
|
+
exit 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
Write-Host ""
|
|
32
|
+
Write-Host "Tunnel is up." -ForegroundColor Green
|
|
33
|
+
Write-Host " MCP URL (give to Muse): $url/mcp"
|
|
34
|
+
Write-Host " OpenAPI fallback: $url/openapi.json"
|
|
35
|
+
Write-Host " Bearer token: BRIDGE_TOKEN from .env (paste into the client's credential prompt, not a chat)"
|
|
36
|
+
Write-Host " Approvals: ntfy app, topic BRIDGE_NTFY_TOPIC from .env"
|
|
37
|
+
Write-Host ""
|
|
38
|
+
|
|
39
|
+
$env:BRIDGE_PUBLIC_URL = $url
|
|
40
|
+
try {
|
|
41
|
+
node server.mjs
|
|
42
|
+
} finally {
|
|
43
|
+
Stop-Process -Id $tunnel.Id -ErrorAction SilentlyContinue
|
|
44
|
+
Write-Host "Tunnel stopped."
|
|
45
|
+
}
|
package/threads.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Side threads: an agent-to-agent channel per PC session, kept apart from the user's own conversation.
|
|
2
|
+
// Each message carries an explicit role so it's never ambiguous who said what:
|
|
3
|
+
// muse Meta Muse (via the bridge)
|
|
4
|
+
// side-claude the side agent answering Muse (a read-only fork of the session)
|
|
5
|
+
// desktop-claude the main session's Claude, posting via the muse-inbox MCP tools
|
|
6
|
+
// desktop-codex the main Codex session, posting via the muse-inbox MCP tools
|
|
7
|
+
// user the user, posting from the local viewer
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import crypto from "node:crypto";
|
|
11
|
+
import { DATA } from "./lib.mjs";
|
|
12
|
+
|
|
13
|
+
const DIR = path.join(DATA, "threads");
|
|
14
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
15
|
+
|
|
16
|
+
export const ROLES = ["muse", "side-claude", "desktop-claude", "desktop-codex", "user"];
|
|
17
|
+
const validId = (id) => /^[0-9a-f-]{36}$/i.test(id || "");
|
|
18
|
+
const logFile = (id) => path.join(DIR, `${id}.jsonl`);
|
|
19
|
+
const metaFile = (id) => path.join(DIR, `${id}.meta.json`);
|
|
20
|
+
|
|
21
|
+
export function appendMessage(sessionId, role, text, extra = {}) {
|
|
22
|
+
if (!validId(sessionId)) throw new Error("Invalid session_id");
|
|
23
|
+
if (!ROLES.includes(role)) throw new Error(`Invalid role ${role}`);
|
|
24
|
+
const msg = { id: crypto.randomUUID(), at: new Date().toISOString(), role, text, ...extra };
|
|
25
|
+
fs.appendFileSync(logFile(sessionId), JSON.stringify(msg) + "\n");
|
|
26
|
+
return msg;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readThread(sessionId, { since } = {}) {
|
|
30
|
+
if (!validId(sessionId) || !fs.existsSync(logFile(sessionId))) return [];
|
|
31
|
+
const all = fs.readFileSync(logFile(sessionId), "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
32
|
+
if (!since) return all;
|
|
33
|
+
const i = all.findIndex((m) => m.id === since);
|
|
34
|
+
return i >= 0 ? all.slice(i + 1) : all.filter((m) => m.at > since);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getMeta(sessionId) {
|
|
38
|
+
if (!validId(sessionId) || !fs.existsSync(metaFile(sessionId))) return {};
|
|
39
|
+
return JSON.parse(fs.readFileSync(metaFile(sessionId), "utf8"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function setMeta(sessionId, meta) {
|
|
43
|
+
fs.writeFileSync(metaFile(sessionId), JSON.stringify({ ...getMeta(sessionId), ...meta }, null, 2));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function listThreads() {
|
|
47
|
+
return fs.readdirSync(DIR).filter((f) => f.endsWith(".jsonl")).map((f) => {
|
|
48
|
+
const sessionId = f.replace(/\.jsonl$/, "");
|
|
49
|
+
const msgs = readThread(sessionId);
|
|
50
|
+
const last = msgs[msgs.length - 1];
|
|
51
|
+
return { session_id: sessionId, title: getMeta(sessionId).title || null, messages: msgs.length, last_at: last?.at, last_role: last?.role };
|
|
52
|
+
}).sort((a, b) => (b.last_at || "").localeCompare(a.last_at || ""));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fork sessions created for side agents, so they can be hidden from list_sessions.
|
|
56
|
+
export function sideForkIds() {
|
|
57
|
+
return new Set(fs.readdirSync(DIR).filter((f) => f.endsWith(".meta.json"))
|
|
58
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(DIR, f), "utf8")).forkSessionId).filter(Boolean));
|
|
59
|
+
}
|