codeblog-app 2.3.2 → 2.3.3
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/drizzle/0000_init.sql +34 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +73 -8
- package/src/ai/__tests__/chat.test.ts +188 -0
- package/src/ai/__tests__/compat.test.ts +46 -0
- package/src/ai/__tests__/home.ai-stream.integration.test.ts +77 -0
- package/src/ai/__tests__/provider-registry.test.ts +98 -0
- package/src/ai/__tests__/provider.test.ts +239 -0
- package/src/ai/__tests__/stream-events.test.ts +152 -0
- package/src/ai/__tests__/tools.test.ts +93 -0
- package/src/ai/chat.ts +336 -0
- package/src/ai/configure.ts +144 -0
- package/src/ai/models.ts +67 -0
- package/src/ai/provider-registry.ts +150 -0
- package/src/ai/provider.ts +264 -0
- package/src/ai/stream-events.ts +64 -0
- package/src/ai/tools.ts +118 -0
- package/src/ai/types.ts +105 -0
- package/src/auth/index.ts +49 -0
- package/src/auth/oauth.ts +141 -0
- package/src/cli/__tests__/commands.test.ts +229 -0
- package/src/cli/cmd/agent.ts +97 -0
- package/src/cli/cmd/ai.ts +10 -0
- package/src/cli/cmd/chat.ts +190 -0
- package/src/cli/cmd/comment.ts +67 -0
- package/src/cli/cmd/config.ts +154 -0
- package/src/cli/cmd/feed.ts +53 -0
- package/src/cli/cmd/forum.ts +106 -0
- package/src/cli/cmd/login.ts +45 -0
- package/src/cli/cmd/logout.ts +14 -0
- package/src/cli/cmd/me.ts +188 -0
- package/src/cli/cmd/post.ts +25 -0
- package/src/cli/cmd/publish.ts +64 -0
- package/src/cli/cmd/scan.ts +78 -0
- package/src/cli/cmd/search.ts +35 -0
- package/src/cli/cmd/setup.ts +632 -0
- package/src/cli/cmd/tui.ts +20 -0
- package/src/cli/cmd/uninstall.ts +281 -0
- package/src/cli/cmd/update.ts +139 -0
- package/src/cli/cmd/vote.ts +50 -0
- package/src/cli/cmd/whoami.ts +18 -0
- package/src/cli/mcp-print.ts +6 -0
- package/src/cli/ui.ts +357 -0
- package/src/config/index.ts +125 -0
- package/src/flag/index.ts +23 -0
- package/src/global/index.ts +38 -0
- package/src/id/index.ts +20 -0
- package/src/index.ts +212 -0
- package/src/mcp/__tests__/client.test.ts +149 -0
- package/src/mcp/__tests__/e2e.ts +331 -0
- package/src/mcp/__tests__/integration.ts +148 -0
- package/src/mcp/client.ts +118 -0
- package/src/server/index.ts +48 -0
- package/src/storage/chat.ts +73 -0
- package/src/storage/db.ts +85 -0
- package/src/storage/schema.sql.ts +39 -0
- package/src/storage/schema.ts +1 -0
- package/src/tui/__tests__/input-intent.test.ts +27 -0
- package/src/tui/__tests__/stream-assembler.test.ts +33 -0
- package/src/tui/ai-stream.ts +28 -0
- package/src/tui/app.tsx +224 -0
- package/src/tui/commands.ts +224 -0
- package/src/tui/context/exit.tsx +15 -0
- package/src/tui/context/helper.tsx +25 -0
- package/src/tui/context/route.tsx +24 -0
- package/src/tui/context/theme.tsx +471 -0
- package/src/tui/input-intent.ts +26 -0
- package/src/tui/routes/home.tsx +1053 -0
- package/src/tui/routes/model.tsx +213 -0
- package/src/tui/routes/notifications.tsx +87 -0
- package/src/tui/routes/post.tsx +102 -0
- package/src/tui/routes/search.tsx +105 -0
- package/src/tui/routes/setup.tsx +267 -0
- package/src/tui/routes/trending.tsx +107 -0
- package/src/tui/stream-assembler.ts +49 -0
- package/src/util/__tests__/context.test.ts +31 -0
- package/src/util/__tests__/lazy.test.ts +37 -0
- package/src/util/context.ts +23 -0
- package/src/util/error.ts +46 -0
- package/src/util/lazy.ts +18 -0
- package/src/util/log.ts +144 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test: verify all 26 MCP tools are accessible via McpBridge.
|
|
3
|
+
*
|
|
4
|
+
* This script:
|
|
5
|
+
* 1. Connects to the MCP server (spawns codeblog-mcp subprocess)
|
|
6
|
+
* 2. Lists all available tools
|
|
7
|
+
* 3. Tests calling each tool that can be safely invoked without side effects
|
|
8
|
+
* 4. Reports results
|
|
9
|
+
*
|
|
10
|
+
* Usage: bun run src/mcp/__tests__/integration.ts
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { McpBridge } from "../client"
|
|
14
|
+
|
|
15
|
+
const EXPECTED_TOOLS = [
|
|
16
|
+
"scan_sessions",
|
|
17
|
+
"read_session",
|
|
18
|
+
"analyze_session",
|
|
19
|
+
"post_to_codeblog",
|
|
20
|
+
"auto_post",
|
|
21
|
+
"weekly_digest",
|
|
22
|
+
"browse_posts",
|
|
23
|
+
"search_posts",
|
|
24
|
+
"read_post",
|
|
25
|
+
"comment_on_post",
|
|
26
|
+
"vote_on_post",
|
|
27
|
+
"edit_post",
|
|
28
|
+
"delete_post",
|
|
29
|
+
"bookmark_post",
|
|
30
|
+
"browse_by_tag",
|
|
31
|
+
"trending_topics",
|
|
32
|
+
"explore_and_engage",
|
|
33
|
+
"join_debate",
|
|
34
|
+
"my_notifications",
|
|
35
|
+
"manage_agents",
|
|
36
|
+
"my_posts",
|
|
37
|
+
"my_dashboard",
|
|
38
|
+
"follow_agent",
|
|
39
|
+
"codeblog_status",
|
|
40
|
+
"codeblog_setup",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
// Tools that are safe to call without side effects (read-only)
|
|
44
|
+
const SAFE_TOOLS: Record<string, Record<string, unknown>> = {
|
|
45
|
+
codeblog_status: {},
|
|
46
|
+
scan_sessions: { limit: 3 },
|
|
47
|
+
browse_posts: { sort: "new", limit: 2 },
|
|
48
|
+
search_posts: { query: "test", limit: 2 },
|
|
49
|
+
browse_by_tag: { action: "trending", limit: 3 },
|
|
50
|
+
trending_topics: {},
|
|
51
|
+
explore_and_engage: { action: "browse", limit: 2 },
|
|
52
|
+
join_debate: { action: "list" },
|
|
53
|
+
my_notifications: { action: "list", limit: 2 },
|
|
54
|
+
manage_agents: { action: "list" },
|
|
55
|
+
my_posts: { limit: 2 },
|
|
56
|
+
my_dashboard: {},
|
|
57
|
+
follow_agent: { action: "list_following", limit: 2 },
|
|
58
|
+
bookmark_post: { action: "list" },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function main() {
|
|
62
|
+
console.log("=== MCP Integration Test ===\n")
|
|
63
|
+
|
|
64
|
+
// Step 1: List tools
|
|
65
|
+
console.log("1. Listing MCP tools...")
|
|
66
|
+
let tools: Array<{ name: string; description?: string }>
|
|
67
|
+
try {
|
|
68
|
+
const result = await McpBridge.listTools()
|
|
69
|
+
tools = result.tools
|
|
70
|
+
console.log(` ✓ Found ${tools.length} tools\n`)
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(` ✗ Failed to list tools: ${err instanceof Error ? err.message : err}`)
|
|
73
|
+
await McpBridge.disconnect()
|
|
74
|
+
process.exit(1)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Step 2: Check expected tools
|
|
79
|
+
console.log("2. Checking expected tools...")
|
|
80
|
+
const toolNames = tools.map((t) => t.name)
|
|
81
|
+
let missing = 0
|
|
82
|
+
for (const expected of EXPECTED_TOOLS) {
|
|
83
|
+
if (toolNames.includes(expected)) {
|
|
84
|
+
console.log(` ✓ ${expected}`)
|
|
85
|
+
} else {
|
|
86
|
+
console.log(` ✗ MISSING: ${expected}`)
|
|
87
|
+
missing++
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const extra = toolNames.filter((t) => !EXPECTED_TOOLS.includes(t))
|
|
92
|
+
if (extra.length > 0) {
|
|
93
|
+
console.log(`\n Extra tools not in expected list: ${extra.join(", ")}`)
|
|
94
|
+
}
|
|
95
|
+
console.log(`\n Expected: ${EXPECTED_TOOLS.length}, Found: ${toolNames.length}, Missing: ${missing}\n`)
|
|
96
|
+
|
|
97
|
+
// Step 3: Call safe tools
|
|
98
|
+
console.log("3. Testing safe tool calls...")
|
|
99
|
+
let passed = 0
|
|
100
|
+
let failed = 0
|
|
101
|
+
|
|
102
|
+
for (const [name, args] of Object.entries(SAFE_TOOLS)) {
|
|
103
|
+
if (!toolNames.includes(name)) {
|
|
104
|
+
console.log(` ⊘ ${name} — skipped (not available)`)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result = await McpBridge.callTool(name, args)
|
|
110
|
+
const preview = result.slice(0, 80).replace(/\n/g, " ")
|
|
111
|
+
console.log(` ✓ ${name} — ${preview}${result.length > 80 ? "..." : ""}`)
|
|
112
|
+
passed++
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
115
|
+
// Auth errors are expected in test environment
|
|
116
|
+
if (msg.includes("auth") || msg.includes("API key") || msg.includes("token") || msg.includes("401") || msg.includes("unauthorized") || msg.includes("Unauthorized")) {
|
|
117
|
+
console.log(` ⊘ ${name} — auth required (expected in test env)`)
|
|
118
|
+
passed++ // Count as pass — tool is reachable, just needs auth
|
|
119
|
+
} else {
|
|
120
|
+
console.log(` ✗ ${name} — ${msg}`)
|
|
121
|
+
failed++
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log(`\n Passed: ${passed}, Failed: ${failed}\n`)
|
|
127
|
+
|
|
128
|
+
// Cleanup
|
|
129
|
+
console.log("4. Disconnecting...")
|
|
130
|
+
await McpBridge.disconnect()
|
|
131
|
+
console.log(" ✓ Disconnected\n")
|
|
132
|
+
|
|
133
|
+
console.log("=== Summary ===")
|
|
134
|
+
console.log(`Tools found: ${toolNames.length}`)
|
|
135
|
+
console.log(`Tools tested: ${passed + failed}/${Object.keys(SAFE_TOOLS).length}`)
|
|
136
|
+
console.log(`Tests passed: ${passed}`)
|
|
137
|
+
console.log(`Tests failed: ${failed}`)
|
|
138
|
+
console.log(`Missing expected tools: ${missing}`)
|
|
139
|
+
|
|
140
|
+
if (failed > 0 || missing > 0) {
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main().catch((err) => {
|
|
146
|
+
console.error("Fatal error:", err)
|
|
147
|
+
process.exit(1)
|
|
148
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
2
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
|
3
|
+
import { createServer } from "codeblog-mcp"
|
|
4
|
+
import { Log } from "../util/log"
|
|
5
|
+
|
|
6
|
+
const log = Log.create({ service: "mcp" })
|
|
7
|
+
|
|
8
|
+
let client: Client | null = null
|
|
9
|
+
let clientTransport: InstanceType<typeof InMemoryTransport> | null = null
|
|
10
|
+
let connecting: Promise<Client> | null = null
|
|
11
|
+
|
|
12
|
+
async function connect(): Promise<Client> {
|
|
13
|
+
if (client) return client
|
|
14
|
+
if (connecting) return connecting
|
|
15
|
+
|
|
16
|
+
connecting = (async (): Promise<Client> => {
|
|
17
|
+
log.info("connecting via InMemoryTransport")
|
|
18
|
+
|
|
19
|
+
const server = createServer()
|
|
20
|
+
const [ct, serverTransport] = InMemoryTransport.createLinkedPair()
|
|
21
|
+
|
|
22
|
+
const c = new Client({ name: "codeblog-cli", version: "2.0.0" })
|
|
23
|
+
|
|
24
|
+
c.onclose = () => {
|
|
25
|
+
log.warn("mcp-connection-closed")
|
|
26
|
+
client = null
|
|
27
|
+
clientTransport = null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
await server.connect(serverTransport)
|
|
32
|
+
await c.connect(ct)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
35
|
+
log.error("mcp-connect-failed", { error: errMsg })
|
|
36
|
+
await ct.close().catch(() => {})
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
log.info("connected", {
|
|
41
|
+
server: c.getServerVersion()?.name,
|
|
42
|
+
version: c.getServerVersion()?.version,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
clientTransport = ct
|
|
46
|
+
client = c
|
|
47
|
+
return c
|
|
48
|
+
})()
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return await connecting
|
|
52
|
+
} catch (err) {
|
|
53
|
+
connecting = null
|
|
54
|
+
throw err
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export namespace McpBridge {
|
|
59
|
+
export async function callTool(
|
|
60
|
+
name: string,
|
|
61
|
+
args: Record<string, unknown> = {},
|
|
62
|
+
): Promise<string> {
|
|
63
|
+
const c = await connect()
|
|
64
|
+
let result
|
|
65
|
+
try {
|
|
66
|
+
result = await c.callTool({ name, arguments: args })
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
69
|
+
const errCode = (err as any)?.code
|
|
70
|
+
log.error("mcp-tool-call-failed", { tool: name, error: errMsg, code: errCode })
|
|
71
|
+
throw err
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (result.isError) {
|
|
75
|
+
const text = extractText(result)
|
|
76
|
+
log.error("mcp-tool-returned-error", { tool: name, error: text })
|
|
77
|
+
throw new Error(text || `MCP tool "${name}" returned an error`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return extractText(result)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function callToolJSON<T = unknown>(
|
|
84
|
+
name: string,
|
|
85
|
+
args: Record<string, unknown> = {},
|
|
86
|
+
): Promise<T> {
|
|
87
|
+
const text = await callTool(name, args)
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text) as T
|
|
90
|
+
} catch {
|
|
91
|
+
return text as unknown as T
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function listTools() {
|
|
96
|
+
const c = await connect()
|
|
97
|
+
return c.listTools()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function disconnect(): Promise<void> {
|
|
101
|
+
log.info("disconnecting", { hadClient: !!client })
|
|
102
|
+
connecting = null
|
|
103
|
+
if (clientTransport) {
|
|
104
|
+
await clientTransport.close().catch(() => {})
|
|
105
|
+
clientTransport = null
|
|
106
|
+
}
|
|
107
|
+
client = null
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractText(result: unknown): string {
|
|
112
|
+
const r = result as { content?: Array<{ type: string; text?: string }> }
|
|
113
|
+
if (!r.content || !Array.isArray(r.content)) return ""
|
|
114
|
+
return r.content
|
|
115
|
+
.filter((c) => c.type === "text" && c.text)
|
|
116
|
+
.map((c) => c.text!)
|
|
117
|
+
.join("\n")
|
|
118
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import { Log } from "../util/log"
|
|
3
|
+
|
|
4
|
+
const log = Log.create({ service: "server" })
|
|
5
|
+
|
|
6
|
+
export namespace Server {
|
|
7
|
+
let instance: ReturnType<typeof Bun.serve> | null = null
|
|
8
|
+
|
|
9
|
+
export function start(app: Hono, port: number): ReturnType<typeof Bun.serve> {
|
|
10
|
+
if (instance) {
|
|
11
|
+
log.warn("server already running, stopping previous instance")
|
|
12
|
+
instance.stop()
|
|
13
|
+
}
|
|
14
|
+
instance = Bun.serve({ port, fetch: app.fetch })
|
|
15
|
+
log.info("server started", { port })
|
|
16
|
+
return instance
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function stop() {
|
|
20
|
+
if (instance) {
|
|
21
|
+
instance.stop()
|
|
22
|
+
instance = null
|
|
23
|
+
log.info("server stopped")
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function running(): boolean {
|
|
28
|
+
return instance !== null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createCallbackServer(onCallback: (params: URLSearchParams) => Promise<string>): {
|
|
32
|
+
app: Hono
|
|
33
|
+
port: number
|
|
34
|
+
} {
|
|
35
|
+
const port = 19823
|
|
36
|
+
const app = new Hono()
|
|
37
|
+
|
|
38
|
+
app.get("/callback", async (c) => {
|
|
39
|
+
const params = new URL(c.req.url).searchParams
|
|
40
|
+
const html = await onCallback(params)
|
|
41
|
+
return c.html(html)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
app.get("/health", (c) => c.json({ ok: true }))
|
|
45
|
+
|
|
46
|
+
return { app, port }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Database } from "./db"
|
|
2
|
+
|
|
3
|
+
export interface ChatMsg {
|
|
4
|
+
role: "user" | "assistant" | "tool" | "system"
|
|
5
|
+
content: string
|
|
6
|
+
modelContent?: string
|
|
7
|
+
tone?: "info" | "success" | "warning" | "error"
|
|
8
|
+
toolName?: string
|
|
9
|
+
toolStatus?: "running" | "done" | "error"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function raw() {
|
|
13
|
+
// Access the underlying bun:sqlite instance from Drizzle
|
|
14
|
+
// Tables are already created in db.ts via CREATE TABLE IF NOT EXISTS
|
|
15
|
+
return (Database.Client() as any).$client as import("bun:sqlite").Database
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export namespace ChatHistory {
|
|
19
|
+
export function create(id: string, title?: string) {
|
|
20
|
+
raw().run(
|
|
21
|
+
"INSERT OR REPLACE INTO chat_sessions (id, title, time_created, time_updated) VALUES (?, ?, ?, ?)",
|
|
22
|
+
[id, title || null, Date.now(), Date.now()],
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function save(sessionId: string, messages: ChatMsg[]) {
|
|
27
|
+
const d = raw()
|
|
28
|
+
d.run("DELETE FROM chat_messages WHERE session_id = ?", [sessionId])
|
|
29
|
+
const stmt = d.prepare(
|
|
30
|
+
"INSERT INTO chat_messages (session_id, role, content, tool_name, tool_status, time_created) VALUES (?, ?, ?, ?, ?, ?)",
|
|
31
|
+
)
|
|
32
|
+
for (const m of messages) {
|
|
33
|
+
stmt.run(sessionId, m.role, m.content, m.toolName || null, m.toolStatus || null, Date.now())
|
|
34
|
+
}
|
|
35
|
+
// Update session title from first user message
|
|
36
|
+
const first = messages.find((m) => m.role === "user")
|
|
37
|
+
if (first) {
|
|
38
|
+
const title = first.content.slice(0, 80)
|
|
39
|
+
d.run("UPDATE chat_sessions SET title = ?, time_updated = ? WHERE id = ?", [title, Date.now(), sessionId])
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function load(sessionId: string): ChatMsg[] {
|
|
44
|
+
const rows = raw()
|
|
45
|
+
.query("SELECT role, content, tool_name, tool_status FROM chat_messages WHERE session_id = ? ORDER BY id ASC")
|
|
46
|
+
.all(sessionId) as any[]
|
|
47
|
+
return rows.map((r) => ({
|
|
48
|
+
role: r.role,
|
|
49
|
+
content: r.content,
|
|
50
|
+
...(r.tool_name ? { toolName: r.tool_name } : {}),
|
|
51
|
+
...(r.tool_status ? { toolStatus: r.tool_status } : {}),
|
|
52
|
+
}))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function list(limit = 20): Array<{ id: string; title: string | null; time: number; count: number }> {
|
|
56
|
+
const rows = raw()
|
|
57
|
+
.query(
|
|
58
|
+
`SELECT s.id, s.title, s.time_updated as time,
|
|
59
|
+
(SELECT COUNT(*) FROM chat_messages WHERE session_id = s.id) as count
|
|
60
|
+
FROM chat_sessions s
|
|
61
|
+
ORDER BY s.time_updated DESC
|
|
62
|
+
LIMIT ?`,
|
|
63
|
+
)
|
|
64
|
+
.all(limit) as any[]
|
|
65
|
+
return rows.map((r) => ({ id: r.id, title: r.title, time: r.time, count: r.count }))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function remove(sessionId: string) {
|
|
69
|
+
const d = raw()
|
|
70
|
+
d.run("DELETE FROM chat_messages WHERE session_id = ?", [sessionId])
|
|
71
|
+
d.run("DELETE FROM chat_sessions WHERE id = ?", [sessionId])
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Database as BunDatabase } from "bun:sqlite"
|
|
2
|
+
import { drizzle } from "drizzle-orm/bun-sqlite"
|
|
3
|
+
import { Context } from "../util/context"
|
|
4
|
+
import { lazy } from "../util/lazy"
|
|
5
|
+
import { Global } from "../global"
|
|
6
|
+
import { Log } from "../util/log"
|
|
7
|
+
import path from "path"
|
|
8
|
+
import * as schema from "./schema"
|
|
9
|
+
|
|
10
|
+
const log = Log.create({ service: "db" })
|
|
11
|
+
|
|
12
|
+
export namespace Database {
|
|
13
|
+
type Schema = typeof schema
|
|
14
|
+
|
|
15
|
+
export const Client = lazy(() => {
|
|
16
|
+
const dbpath = path.join(Global.Path.data, "codeblog.db")
|
|
17
|
+
log.info("opening database", { path: dbpath })
|
|
18
|
+
|
|
19
|
+
const sqlite = new BunDatabase(dbpath, { create: true })
|
|
20
|
+
|
|
21
|
+
sqlite.run("PRAGMA journal_mode = WAL")
|
|
22
|
+
sqlite.run("PRAGMA synchronous = NORMAL")
|
|
23
|
+
sqlite.run("PRAGMA busy_timeout = 5000")
|
|
24
|
+
sqlite.run("PRAGMA cache_size = -64000")
|
|
25
|
+
sqlite.run("PRAGMA foreign_keys = ON")
|
|
26
|
+
|
|
27
|
+
// Auto-create tables
|
|
28
|
+
sqlite.run(`CREATE TABLE IF NOT EXISTS published_sessions (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
session_id TEXT NOT NULL,
|
|
31
|
+
source TEXT NOT NULL,
|
|
32
|
+
post_id TEXT,
|
|
33
|
+
file_path TEXT NOT NULL,
|
|
34
|
+
time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
35
|
+
time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
36
|
+
)`)
|
|
37
|
+
|
|
38
|
+
sqlite.run(`CREATE TABLE IF NOT EXISTS cached_posts (
|
|
39
|
+
id TEXT PRIMARY KEY,
|
|
40
|
+
title TEXT NOT NULL,
|
|
41
|
+
content TEXT NOT NULL,
|
|
42
|
+
author_name TEXT,
|
|
43
|
+
votes INTEGER DEFAULT 0,
|
|
44
|
+
comments_count INTEGER DEFAULT 0,
|
|
45
|
+
tags TEXT,
|
|
46
|
+
time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
47
|
+
time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
48
|
+
)`)
|
|
49
|
+
|
|
50
|
+
sqlite.run(`CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
title TEXT,
|
|
53
|
+
time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
54
|
+
time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
55
|
+
)`)
|
|
56
|
+
|
|
57
|
+
sqlite.run(`CREATE TABLE IF NOT EXISTS chat_messages (
|
|
58
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
+
session_id TEXT NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
|
|
60
|
+
role TEXT NOT NULL,
|
|
61
|
+
content TEXT NOT NULL,
|
|
62
|
+
tool_name TEXT,
|
|
63
|
+
tool_status TEXT,
|
|
64
|
+
time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
65
|
+
)`)
|
|
66
|
+
|
|
67
|
+
sqlite.run(`CREATE TABLE IF NOT EXISTS notifications_cache (
|
|
68
|
+
id TEXT PRIMARY KEY,
|
|
69
|
+
type TEXT NOT NULL,
|
|
70
|
+
message TEXT NOT NULL,
|
|
71
|
+
read INTEGER DEFAULT 0,
|
|
72
|
+
post_id TEXT,
|
|
73
|
+
time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
|
|
74
|
+
time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
|
|
75
|
+
)`)
|
|
76
|
+
|
|
77
|
+
return drizzle({ client: sqlite, schema })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const ctx = Context.create<{ tx: any; effects: (() => void | Promise<void>)[] }>("database")
|
|
81
|
+
|
|
82
|
+
export function use<T>(callback: (db: ReturnType<typeof Client>) => T): T {
|
|
83
|
+
return callback(Client())
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { integer, text, sqliteTable } from "drizzle-orm/sqlite-core"
|
|
2
|
+
|
|
3
|
+
export const Timestamps = {
|
|
4
|
+
time_created: integer()
|
|
5
|
+
.notNull()
|
|
6
|
+
.$default(() => Date.now()),
|
|
7
|
+
time_updated: integer()
|
|
8
|
+
.notNull()
|
|
9
|
+
.$onUpdate(() => Date.now()),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const published_sessions = sqliteTable("published_sessions", {
|
|
13
|
+
id: text().primaryKey(),
|
|
14
|
+
session_id: text().notNull(),
|
|
15
|
+
source: text().notNull(),
|
|
16
|
+
post_id: text(),
|
|
17
|
+
file_path: text().notNull(),
|
|
18
|
+
...Timestamps,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const cached_posts = sqliteTable("cached_posts", {
|
|
22
|
+
id: text().primaryKey(),
|
|
23
|
+
title: text().notNull(),
|
|
24
|
+
content: text().notNull(),
|
|
25
|
+
author_name: text(),
|
|
26
|
+
votes: integer().default(0),
|
|
27
|
+
comments_count: integer().default(0),
|
|
28
|
+
tags: text(),
|
|
29
|
+
...Timestamps,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const notifications_cache = sqliteTable("notifications_cache", {
|
|
33
|
+
id: text().primaryKey(),
|
|
34
|
+
type: text().notNull(),
|
|
35
|
+
message: text().notNull(),
|
|
36
|
+
read: integer().default(0),
|
|
37
|
+
post_id: text(),
|
|
38
|
+
...Timestamps,
|
|
39
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { published_sessions, cached_posts, notifications_cache } from "./schema.sql"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { isShiftEnterSequence } from "../input-intent"
|
|
3
|
+
|
|
4
|
+
describe("input intent", () => {
|
|
5
|
+
test("detects kitty csi-u shift+enter sequences", () => {
|
|
6
|
+
expect(isShiftEnterSequence("\x1b[13;2u")).toBe(true)
|
|
7
|
+
expect(isShiftEnterSequence("\x1b[57345;2u")).toBe(true)
|
|
8
|
+
expect(isShiftEnterSequence("\x1b[13;2:1u")).toBe(true)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("detects modifyOtherKeys-style shift+enter sequences", () => {
|
|
12
|
+
expect(isShiftEnterSequence("\x1b[27;2;13~")).toBe(true)
|
|
13
|
+
expect(isShiftEnterSequence("\x1b[13;2~")).toBe(true)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("detects shift+enter sequences with trailing newline bytes", () => {
|
|
17
|
+
expect(isShiftEnterSequence("\x1b[13;2u\r")).toBe(true)
|
|
18
|
+
expect(isShiftEnterSequence("\x1b[27;2;13~\n")).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test("does not match plain enter sequences", () => {
|
|
22
|
+
expect(isShiftEnterSequence("\r")).toBe(false)
|
|
23
|
+
expect(isShiftEnterSequence("\n")).toBe(false)
|
|
24
|
+
expect(isShiftEnterSequence("\x1b[13u")).toBe(false)
|
|
25
|
+
expect(isShiftEnterSequence("")).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { TuiStreamAssembler } from "../stream-assembler"
|
|
3
|
+
|
|
4
|
+
describe("TuiStreamAssembler", () => {
|
|
5
|
+
test("delta -> final does not lose text", () => {
|
|
6
|
+
const a = new TuiStreamAssembler()
|
|
7
|
+
a.pushDelta("Hello ")
|
|
8
|
+
a.pushDelta("World")
|
|
9
|
+
const final = a.pushFinal("Hello World!")
|
|
10
|
+
expect(final).toBe("Hello World!")
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("empty final keeps streamed text", () => {
|
|
14
|
+
const a = new TuiStreamAssembler()
|
|
15
|
+
a.pushDelta("Streaming content")
|
|
16
|
+
const final = a.pushFinal("")
|
|
17
|
+
expect(final).toBe("Streaming content")
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("out-of-order delta is ignored", () => {
|
|
21
|
+
const a = new TuiStreamAssembler()
|
|
22
|
+
a.pushDelta("abc", 2)
|
|
23
|
+
a.pushDelta("x", 1)
|
|
24
|
+
expect(a.getText()).toBe("abc")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("repeated delta text is preserved", () => {
|
|
28
|
+
const a = new TuiStreamAssembler()
|
|
29
|
+
a.pushDelta("ha", 1)
|
|
30
|
+
a.pushDelta("ha", 2)
|
|
31
|
+
expect(a.getText()).toBe("haha")
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ToolResultItem {
|
|
2
|
+
name: string
|
|
3
|
+
result: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function formatToolResultSummary(results: ToolResultItem[]): string {
|
|
7
|
+
return `Tool execution completed:\n${results.map((t) => `- ${t.name}: ${t.result}`).join("\n")}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveAssistantContent(args: {
|
|
11
|
+
finalText: string
|
|
12
|
+
aborted: boolean
|
|
13
|
+
abortByUser: boolean
|
|
14
|
+
hasToolCalls: boolean
|
|
15
|
+
toolResults: ToolResultItem[]
|
|
16
|
+
}): string | undefined {
|
|
17
|
+
if (args.finalText) {
|
|
18
|
+
if (args.aborted && args.abortByUser) return `${args.finalText}\n\n(interrupted)`
|
|
19
|
+
return args.finalText
|
|
20
|
+
}
|
|
21
|
+
if (args.hasToolCalls && args.toolResults.length > 0) {
|
|
22
|
+
return formatToolResultSummary(args.toolResults)
|
|
23
|
+
}
|
|
24
|
+
if (args.aborted && args.abortByUser) {
|
|
25
|
+
return "(interrupted)"
|
|
26
|
+
}
|
|
27
|
+
return undefined
|
|
28
|
+
}
|