codeblog-app 1.6.2 → 1.6.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "codeblog-app",
4
- "version": "1.6.2",
4
+ "version": "1.6.4",
5
5
  "description": "CLI client for CodeBlog — the forum where AI writes the posts",
6
6
  "type": "module",
7
7
  "license": "MIT",
package/src/ai/chat.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { streamText, type CoreMessage } from "ai"
1
+ import { streamText, type CoreMessage, type CoreToolMessage, type CoreAssistantMessage } from "ai"
2
2
  import { AIProvider } from "./provider"
3
3
  import { chatTools } from "./tools"
4
4
  import { Log } from "../util/log"
@@ -35,92 +35,64 @@ export namespace AIChat {
35
35
  onToolResult?: (name: string, result: unknown) => void
36
36
  }
37
37
 
38
+ // Convert our simple messages to CoreMessage[] for AI SDK
39
+ // Only user/assistant text messages — tool history is handled by maxSteps internally
40
+ function toCoreMessages(messages: Message[]): CoreMessage[] {
41
+ return messages
42
+ .filter((m) => m.role === "user" || m.role === "assistant")
43
+ .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }))
44
+ }
45
+
38
46
  export async function stream(messages: Message[], callbacks: StreamCallbacks, modelID?: string, signal?: AbortSignal) {
39
47
  const model = await AIProvider.getModel(modelID)
40
48
  log.info("streaming", { model: modelID || AIProvider.DEFAULT_MODEL, messages: messages.length })
41
49
 
42
- const history: CoreMessage[] = messages.map((m) => ({ role: m.role, content: m.content }))
50
+ const coreMessages = toCoreMessages(messages)
43
51
  let full = ""
44
52
 
45
- // Manual multi-step loop (maxSteps=1 per call, we handle tool follow-up ourselves)
46
- // This is needed because openai-compatible providers don't support automatic multi-step
47
- for (let step = 0; step < 5; step++) {
48
- if (signal?.aborted) break
49
-
50
- const result = streamText({
51
- model,
52
- system: SYSTEM_PROMPT,
53
- messages: history,
54
- tools: chatTools,
55
- maxSteps: 1,
56
- abortSignal: signal,
57
- })
58
-
59
- const calls: Array<{ id: string; name: string; input: unknown; output: unknown }> = []
60
-
61
- try {
62
- for await (const part of result.fullStream) {
63
- if (signal?.aborted) break
64
- switch (part.type) {
65
- case "text-delta": {
66
- const delta = (part as any).text ?? (part as any).textDelta ?? ""
67
- if (delta) { full += delta; callbacks.onToken?.(delta) }
68
- break
69
- }
70
- case "tool-call": {
71
- const input = (part as any).input ?? (part as any).args
72
- callbacks.onToolCall?.(part.toolName, input)
73
- calls.push({ id: part.toolCallId, name: part.toolName, input, output: undefined })
74
- break
75
- }
76
- case "tool-result": {
77
- const output = (part as any).output ?? (part as any).result ?? {}
78
- const name = (part as any).toolName
79
- callbacks.onToolResult?.(name, output)
80
- const match = calls.find((c) => c.name === name && c.output === undefined)
81
- if (match) match.output = output
82
- break
83
- }
84
- case "error": {
85
- const msg = part.error instanceof Error ? part.error.message : String(part.error)
86
- log.error("stream part error", { error: msg })
87
- callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
88
- break
89
- }
53
+ const result = streamText({
54
+ model,
55
+ system: SYSTEM_PROMPT,
56
+ messages: coreMessages,
57
+ tools: chatTools,
58
+ maxSteps: 5,
59
+ abortSignal: signal,
60
+ })
61
+
62
+ try {
63
+ for await (const part of result.fullStream) {
64
+ if (signal?.aborted) break
65
+ switch (part.type) {
66
+ case "text-delta": {
67
+ const delta = (part as any).text ?? (part as any).textDelta ?? ""
68
+ if (delta) { full += delta; callbacks.onToken?.(delta) }
69
+ break
70
+ }
71
+ case "tool-call": {
72
+ const input = (part as any).input ?? (part as any).args
73
+ callbacks.onToolCall?.(part.toolName, input)
74
+ break
75
+ }
76
+ case "tool-result": {
77
+ const output = (part as any).output ?? (part as any).result ?? {}
78
+ const name = (part as any).toolName
79
+ callbacks.onToolResult?.(name, output)
80
+ break
81
+ }
82
+ case "error": {
83
+ const msg = part.error instanceof Error ? part.error.message : String(part.error)
84
+ log.error("stream part error", { error: msg })
85
+ callbacks.onError?.(part.error instanceof Error ? part.error : new Error(msg))
86
+ break
90
87
  }
91
88
  }
92
- } catch (err) {
93
- const error = err instanceof Error ? err : new Error(String(err))
94
- log.error("stream error", { error: error.message })
95
- if (callbacks.onError) callbacks.onError(error)
96
- else throw error
97
- return full
98
89
  }
99
-
100
- // No tool calls this step done
101
- if (calls.length === 0) break
102
-
103
- // Append assistant + tool messages in AI SDK v6 format for next round
104
- history.push({
105
- role: "assistant",
106
- content: calls.map((c) => ({
107
- type: "tool-call" as const,
108
- toolCallId: c.id,
109
- toolName: c.name,
110
- input: c.input ?? {},
111
- })),
112
- } as any)
113
- history.push({
114
- role: "tool",
115
- content: calls.map((c) => ({
116
- type: "tool-result" as const,
117
- toolCallId: c.id,
118
- toolName: c.name,
119
- output: { type: "json", value: c.output ?? {} },
120
- })),
121
- } as any)
122
-
123
- log.info("tool round done, sending follow-up", { step, tools: calls.map((c) => c.name) })
90
+ } catch (err) {
91
+ const error = err instanceof Error ? err : new Error(String(err))
92
+ log.error("stream error", { error: error.message })
93
+ if (callbacks.onError) callbacks.onError(error)
94
+ else throw error
95
+ return full
124
96
  }
125
97
 
126
98
  callbacks.onFinish?.(full || "(No response)")
@@ -0,0 +1,92 @@
1
+ import { Database as BunDatabase } from "bun:sqlite"
2
+ import { Global } from "../global"
3
+ import path from "path"
4
+
5
+ function db() {
6
+ const dbpath = path.join(Global.Path.data, "codeblog.db")
7
+ const sqlite = new BunDatabase(dbpath, { create: true })
8
+ sqlite.run("PRAGMA journal_mode = WAL")
9
+ sqlite.run("PRAGMA foreign_keys = ON")
10
+
11
+ sqlite.run(`CREATE TABLE IF NOT EXISTS chat_sessions (
12
+ id TEXT PRIMARY KEY,
13
+ title TEXT,
14
+ time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
15
+ time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
16
+ )`)
17
+
18
+ sqlite.run(`CREATE TABLE IF NOT EXISTS chat_messages (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ session_id TEXT NOT NULL,
21
+ role TEXT NOT NULL,
22
+ content TEXT NOT NULL,
23
+ tool_name TEXT,
24
+ tool_status TEXT,
25
+ time_created INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
26
+ )`)
27
+
28
+ return sqlite
29
+ }
30
+
31
+ export interface ChatMsg {
32
+ role: "user" | "assistant" | "tool"
33
+ content: string
34
+ toolName?: string
35
+ toolStatus?: "running" | "done" | "error"
36
+ }
37
+
38
+ export namespace ChatHistory {
39
+ export function create(id: string, title?: string) {
40
+ const d = db()
41
+ d.run("INSERT OR REPLACE INTO chat_sessions (id, title, time_created, time_updated) VALUES (?, ?, ?, ?)", [id, title || null, Date.now(), Date.now()])
42
+ d.close()
43
+ }
44
+
45
+ export function save(sessionId: string, messages: ChatMsg[]) {
46
+ const d = db()
47
+ d.run("DELETE FROM chat_messages WHERE session_id = ?", [sessionId])
48
+ const stmt = d.prepare("INSERT INTO chat_messages (session_id, role, content, tool_name, tool_status, time_created) VALUES (?, ?, ?, ?, ?, ?)")
49
+ for (const m of messages) {
50
+ stmt.run(sessionId, m.role, m.content, m.toolName || null, m.toolStatus || null, Date.now())
51
+ }
52
+ // Update session title from first user message
53
+ const first = messages.find((m) => m.role === "user")
54
+ if (first) {
55
+ const title = first.content.slice(0, 80)
56
+ d.run("UPDATE chat_sessions SET title = ?, time_updated = ? WHERE id = ?", [title, Date.now(), sessionId])
57
+ }
58
+ d.close()
59
+ }
60
+
61
+ export function load(sessionId: string): ChatMsg[] {
62
+ const d = db()
63
+ const rows = d.query("SELECT role, content, tool_name, tool_status FROM chat_messages WHERE session_id = ? ORDER BY id ASC").all(sessionId) as any[]
64
+ d.close()
65
+ return rows.map((r) => ({
66
+ role: r.role,
67
+ content: r.content,
68
+ ...(r.tool_name ? { toolName: r.tool_name } : {}),
69
+ ...(r.tool_status ? { toolStatus: r.tool_status } : {}),
70
+ }))
71
+ }
72
+
73
+ export function list(limit = 20): Array<{ id: string; title: string | null; time: number; count: number }> {
74
+ const d = db()
75
+ const rows = d.query(`
76
+ SELECT s.id, s.title, s.time_updated as time,
77
+ (SELECT COUNT(*) FROM chat_messages WHERE session_id = s.id) as count
78
+ FROM chat_sessions s
79
+ ORDER BY s.time_updated DESC
80
+ LIMIT ?
81
+ `).all(limit) as any[]
82
+ d.close()
83
+ return rows.map((r) => ({ id: r.id, title: r.title, time: r.time, count: r.count }))
84
+ }
85
+
86
+ export function remove(sessionId: string) {
87
+ const d = db()
88
+ d.run("DELETE FROM chat_messages WHERE session_id = ?", [sessionId])
89
+ d.run("DELETE FROM chat_sessions WHERE id = ?", [sessionId])
90
+ d.close()
91
+ }
92
+ }
package/src/storage/db.ts CHANGED
@@ -47,6 +47,23 @@ export namespace Database {
47
47
  time_updated INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
48
48
  )`)
49
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
+
50
67
  sqlite.run(`CREATE TABLE IF NOT EXISTS notifications_cache (
51
68
  id TEXT PRIMARY KEY,
52
69
  type TEXT NOT NULL,
@@ -16,6 +16,8 @@ export interface CommandDeps {
16
16
  startAIConfig: () => void
17
17
  setMode: (mode: "dark" | "light") => void
18
18
  send: (prompt: string) => void
19
+ resume: (id?: string) => void
20
+ listSessions: () => Array<{ id: string; title: string | null; time: number; count: number }>
19
21
  colors: {
20
22
  primary: string
21
23
  success: string
@@ -49,6 +51,15 @@ export function createCommands(deps: CommandDeps): CmdDef[] {
49
51
  { name: "/dark", description: "Switch to dark mode", action: () => { deps.setMode("dark"); deps.showMsg("Dark mode", deps.colors.text) } },
50
52
  { name: "/light", description: "Switch to light mode", action: () => { deps.setMode("light"); deps.showMsg("Light mode", deps.colors.text) } },
51
53
  { name: "/exit", description: "Exit CodeBlog", action: () => deps.exit() },
54
+ { name: "/resume", description: "Resume last chat session", action: (parts) => deps.resume(parts[1]) },
55
+ { name: "/history", description: "Show recent chat sessions", action: () => {
56
+ try {
57
+ const sessions = deps.listSessions()
58
+ if (sessions.length === 0) { deps.showMsg("No chat history yet", deps.colors.warning); return }
59
+ const lines = sessions.map((s, i) => `${i + 1}. ${s.title || "(untitled)"} (${s.count} msgs, ${new Date(s.time).toLocaleDateString()})`)
60
+ deps.showMsg(lines.join(" | "), deps.colors.text)
61
+ } catch { deps.showMsg("Failed to load history", deps.colors.error) }
62
+ }},
52
63
 
53
64
  // === Session tools (scan_sessions, read_session, analyze_session) ===
54
65
  { name: "/scan", description: "Scan IDE coding sessions", action: () => deps.send("Scan my local IDE coding sessions and tell me what you found. Show sources, projects, and session counts.") },
@@ -6,6 +6,7 @@ import { useTheme } from "../context/theme"
6
6
  import { createCommands, LOGO, TIPS } from "../commands"
7
7
  import { TOOL_LABELS } from "../../ai/tools"
8
8
  import { mask, saveProvider } from "../../ai/configure"
9
+ import { ChatHistory } from "../../storage/chat"
9
10
 
10
11
  interface ChatMsg {
11
12
  role: "user" | "assistant" | "tool"
@@ -37,8 +38,36 @@ export function Home(props: {
37
38
  const [streamText, setStreamText] = createSignal("")
38
39
  let abortCtrl: AbortController | undefined
39
40
  let escCooldown = 0
41
+ let sessionId = ""
40
42
  const chatting = createMemo(() => messages().length > 0 || streaming())
41
43
 
44
+ function ensureSession() {
45
+ if (!sessionId) {
46
+ sessionId = crypto.randomUUID()
47
+ try { ChatHistory.create(sessionId) } catch {}
48
+ }
49
+ }
50
+
51
+ function saveChat() {
52
+ if (!sessionId) return
53
+ try { ChatHistory.save(sessionId, messages()) } catch {}
54
+ }
55
+
56
+ function resumeSession(sid?: string) {
57
+ try {
58
+ if (!sid) {
59
+ const sessions = ChatHistory.list(1)
60
+ if (sessions.length === 0) { showMsg("No previous sessions", theme.colors.warning); return }
61
+ sid = sessions[0].id
62
+ }
63
+ const msgs = ChatHistory.load(sid)
64
+ if (msgs.length === 0) { showMsg("Session is empty", theme.colors.warning); return }
65
+ sessionId = sid
66
+ setMessages(msgs as ChatMsg[])
67
+ showMsg("Resumed session", theme.colors.success)
68
+ } catch { showMsg("Failed to resume", theme.colors.error) }
69
+ }
70
+
42
71
  // Shimmer animation for thinking state (like Claude Code)
43
72
  const SHIMMER_WORDS = ["Thinking", "Reasoning", "Composing", "Reflecting", "Analyzing", "Processing"]
44
73
  const [shimmerIdx, setShimmerIdx] = createSignal(0)
@@ -65,6 +94,7 @@ export function Home(props: {
65
94
 
66
95
  function clearChat() {
67
96
  setMessages([]); setStreamText(""); setStreaming(false); setMessage("")
97
+ sessionId = ""
68
98
  }
69
99
 
70
100
  const commands = createCommands({
@@ -80,6 +110,8 @@ export function Home(props: {
80
110
  },
81
111
  setMode: theme.setMode,
82
112
  send,
113
+ resume: resumeSession,
114
+ listSessions: () => { try { return ChatHistory.list(10) } catch { return [] } },
83
115
  colors: theme.colors,
84
116
  })
85
117
 
@@ -110,6 +142,7 @@ export function Home(props: {
110
142
 
111
143
  async function send(text: string) {
112
144
  if (!text.trim() || streaming()) return
145
+ ensureSession()
113
146
  const userMsg: ChatMsg = { role: "user", content: text.trim() }
114
147
  const prev = messages()
115
148
  setMessages([...prev, userMsg])
@@ -161,6 +194,7 @@ export function Home(props: {
161
194
  return [...updated, { role: "assistant" as const, content: `Error: ${err.message}` }]
162
195
  })
163
196
  setStreamText(""); setStreaming(false)
197
+ saveChat()
164
198
  },
165
199
  }, mid, abortCtrl.signal)
166
200
  abortCtrl = undefined
@@ -169,6 +203,7 @@ export function Home(props: {
169
203
  setMessages((p) => [...p, { role: "assistant", content: `Error: ${msg}` }])
170
204
  setStreamText("")
171
205
  setStreaming(false)
206
+ saveChat()
172
207
  }
173
208
  }
174
209