@wenathlan/devthink 1.1.1
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/CHANGELOG.md +44 -0
- package/LICENSE +674 -0
- package/README.md +170 -0
- package/build.ts +41 -0
- package/check-secrets.ts +52 -0
- package/compatibility.ts +37 -0
- package/config.ts +240 -0
- package/devthink.ts +360 -0
- package/identity.ts +136 -0
- package/ids.ts +22 -0
- package/memory.ts +95 -0
- package/modes.ts +43 -0
- package/package.json +67 -0
- package/plugin.ts +68 -0
- package/providers.ts +179 -0
- package/server.ts +227 -0
- package/session.ts +155 -0
- package/storage.ts +51 -0
- package/stream.ts +130 -0
- package/sync.ts +27 -0
- package/ui.ts +122 -0
package/session.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** DevThink session model: one stable identity contract for CLI, gateway, local database, and URL-addressable web state. */
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { ensurePaths, type DevThinkPaths } from "./config.ts";
|
|
5
|
+
import type { ChatMessage } from "./providers.ts";
|
|
6
|
+
import { createCompactId } from "./ids.ts";
|
|
7
|
+
import { mirrorSession, type StoredSession, type StoredWorkspace } from "./storage.ts";
|
|
8
|
+
|
|
9
|
+
export type SessionSection = "chat" | "inspector" | "settings" | "memory" | "providers" | "projects" | "routes" | "usage";
|
|
10
|
+
export type SessionTab = { id: string; sessionId: string; workspaceId: string; label: string; provider?: string; sectionId: SessionSection; createdAt: string; updatedAt: string };
|
|
11
|
+
export type SessionMessage = ChatMessage & { id: string; sessionId: string; workspaceId: string; tabId: string; sectionId: SessionSection; createdAt: string };
|
|
12
|
+
export type Workspace = { id: string; title: string; createdAt: string; updatedAt: string };
|
|
13
|
+
|
|
14
|
+
export type Session = {
|
|
15
|
+
id: string;
|
|
16
|
+
workspaceId: string;
|
|
17
|
+
title: string;
|
|
18
|
+
mode: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
provider?: string;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
activeTabId: string;
|
|
24
|
+
tabs: SessionTab[];
|
|
25
|
+
messages: SessionMessage[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function sessionPath(paths: DevThinkPaths, id: string): string {
|
|
29
|
+
return join(paths.sessions, `${id}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function safeTitle(messages: SessionMessage[]): string {
|
|
33
|
+
const first = messages.find((message) => message.role === "user");
|
|
34
|
+
return first?.content.slice(0, 80) || "Untitled session";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function workspacePath(paths: DevThinkPaths, id: string): string {
|
|
38
|
+
return join(paths.workspaces, `${id}.json`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function atomicWrite(path: string, value: unknown): void {
|
|
42
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
43
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
44
|
+
renameSync(temporary, path);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function newWorkspace(id = createCompactId("w"), title = "Untitled workspace", now = new Date().toISOString()): Workspace {
|
|
48
|
+
return { id, title, createdAt: now, updatedAt: now };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function newTab(sessionId: string, workspaceId: string, provider: string | undefined, label: string, sectionId: SessionSection, now: string, id = createCompactId("t")): SessionTab {
|
|
52
|
+
return { id, sessionId, workspaceId, provider, label, sectionId, createdAt: now, updatedAt: now };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeSession(raw: Session): Session {
|
|
56
|
+
const now = raw.updatedAt || new Date().toISOString();
|
|
57
|
+
const workspaceId = raw.workspaceId || createCompactId("w");
|
|
58
|
+
const tabs = raw.tabs?.length ? raw.tabs.map((tab) => ({ ...tab, sessionId: raw.id, workspaceId, sectionId: tab.sectionId || "chat" as SessionSection })) : [newTab(raw.id, workspaceId, raw.provider, raw.title || "Untitled session", "chat", now)];
|
|
59
|
+
const activeTabId = tabs.some((tab) => tab.id === raw.activeTabId) ? raw.activeTabId : tabs[0].id;
|
|
60
|
+
const messages = (raw.messages || []).map((message) => ({ ...message, id: message.id || createCompactId("m"), sessionId: raw.id, workspaceId, tabId: message.tabId || activeTabId, sectionId: message.sectionId || "chat" as SessionSection, createdAt: message.createdAt || now }));
|
|
61
|
+
return { ...raw, workspaceId, activeTabId, tabs, messages };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readSession(path: string): Session | undefined {
|
|
65
|
+
try {
|
|
66
|
+
if (!existsSync(path)) return undefined;
|
|
67
|
+
return normalizeSession(JSON.parse(readFileSync(path, "utf8")) as Session);
|
|
68
|
+
} catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function workspaceFor(session: Session): Workspace {
|
|
74
|
+
const title = session.title === "Untitled session" ? "Untitled workspace" : session.title;
|
|
75
|
+
return newWorkspace(session.workspaceId, title, session.createdAt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function saveSession(paths: DevThinkPaths, session: Session): Session {
|
|
79
|
+
ensurePaths(paths);
|
|
80
|
+
const normalized = normalizeSession(session);
|
|
81
|
+
const existing = loadWorkspace(paths, normalized.workspaceId);
|
|
82
|
+
const workspace = { ...(existing || workspaceFor(normalized)), title: normalized.title === "Untitled session" ? existing?.title || "Untitled workspace" : normalized.title, updatedAt: normalized.updatedAt };
|
|
83
|
+
atomicWrite(sessionPath(paths, normalized.id), normalized);
|
|
84
|
+
atomicWrite(workspacePath(paths, workspace.id), workspace);
|
|
85
|
+
mirrorSession(paths, workspace as StoredWorkspace, normalized as StoredSession);
|
|
86
|
+
return normalized;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createWorkspace(paths: DevThinkPaths, title = "Untitled workspace", id = createCompactId("w")): Workspace {
|
|
90
|
+
ensurePaths(paths);
|
|
91
|
+
const workspace = newWorkspace(id, title);
|
|
92
|
+
atomicWrite(workspacePath(paths, id), workspace);
|
|
93
|
+
return workspace;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function loadWorkspace(paths: DevThinkPaths, id: string): Workspace | undefined {
|
|
97
|
+
try {
|
|
98
|
+
if (!existsSync(workspacePath(paths, id))) return undefined;
|
|
99
|
+
return JSON.parse(readFileSync(workspacePath(paths, id), "utf8")) as Workspace;
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createSession(paths: DevThinkPaths, metadata: Pick<Session, "mode" | "model" | "provider"> & { workspaceId?: string; tabId?: string; sectionId?: SessionSection; title?: string }): Session {
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
const id = createCompactId("s");
|
|
108
|
+
const workspace = metadata.workspaceId ? loadWorkspace(paths, metadata.workspaceId) || createWorkspace(paths, "Untitled workspace", metadata.workspaceId) : createWorkspace(paths);
|
|
109
|
+
const tab = newTab(id, workspace.id, metadata.provider, metadata.title || "Untitled session", metadata.sectionId || "chat", now, metadata.tabId);
|
|
110
|
+
const session: Session = { id, workspaceId: workspace.id, title: metadata.title || "Untitled session", mode: metadata.mode, model: metadata.model, provider: metadata.provider, createdAt: now, updatedAt: now, activeTabId: tab.id, tabs: [tab], messages: [] };
|
|
111
|
+
return saveSession(paths, session);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function loadSession(paths: DevThinkPaths, id: string): Session | undefined {
|
|
115
|
+
const session = readSession(sessionPath(paths, id));
|
|
116
|
+
return session ? saveSession(paths, session) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function createTab(paths: DevThinkPaths, session: Session, input: { id?: string; label?: string; provider?: string; sectionId?: SessionSection }): Session {
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
const tab = newTab(session.id, session.workspaceId, input.provider || session.provider, input.label || "New session", input.sectionId || "chat", now, input.id);
|
|
122
|
+
return saveSession(paths, { ...session, activeTabId: tab.id, updatedAt: now, tabs: [...session.tabs, tab] });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function updateTab(paths: DevThinkPaths, session: Session, tabId: string, input: Partial<Pick<SessionTab, "label" | "provider" | "sectionId">>): Session {
|
|
126
|
+
const now = new Date().toISOString();
|
|
127
|
+
const tabs = session.tabs.map((tab) => tab.id === tabId ? { ...tab, ...input, updatedAt: now } : tab);
|
|
128
|
+
if (!tabs.some((tab) => tab.id === tabId)) throw new Error(`Tab not found: ${tabId}`);
|
|
129
|
+
return saveSession(paths, { ...session, activeTabId: tabId, updatedAt: now, tabs });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function appendMessage(paths: DevThinkPaths, session: Session, message: ChatMessage, context?: { tabId?: string; sectionId?: SessionSection; id?: string }): Session {
|
|
133
|
+
const now = new Date().toISOString();
|
|
134
|
+
const tabId = context?.tabId || session.activeTabId;
|
|
135
|
+
if (!session.tabs.some((tab) => tab.id === tabId)) throw new Error(`Tab not found: ${tabId}`);
|
|
136
|
+
const nextMessages = [...session.messages, { ...message, id: context?.id || createCompactId("m"), sessionId: session.id, workspaceId: session.workspaceId, tabId, sectionId: context?.sectionId || "chat", createdAt: now }];
|
|
137
|
+
return saveSession(paths, { ...session, title: safeTitle(nextMessages), updatedAt: now, messages: nextMessages });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function listSessions(paths: DevThinkPaths): Session[] {
|
|
141
|
+
if (!existsSync(paths.sessions)) return [];
|
|
142
|
+
return readdirSync(paths.sessions).filter((file) => file.endsWith(".json") && !file.endsWith(".memory.json")).map((file) => readSession(join(paths.sessions, file))).filter((session): session is Session => Boolean(session)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function renderSessionMarkdown(session: Session): string {
|
|
146
|
+
const lines = [`# ${session.title}`, "", `- Workspace: ${session.workspaceId}`, `- Session: ${session.id}`, `- Active tab: ${session.activeTabId}`, `- Mode: ${session.mode}`, `- Provider: ${session.provider || "not configured"}`, `- Model: ${session.model || "not configured"}`, ""];
|
|
147
|
+
for (const message of session.messages) lines.push(`## ${message.role}`, "", message.content, "");
|
|
148
|
+
return `${lines.join("\n")}\n`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function exportSession(paths: DevThinkPaths, id: string, format: "json" | "markdown"): string {
|
|
152
|
+
const session = loadSession(paths, id);
|
|
153
|
+
if (!session) throw new Error(`Session not found: ${id}`);
|
|
154
|
+
return format === "json" ? `${JSON.stringify(session, null, 2)}\n` : renderSessionMarkdown(session);
|
|
155
|
+
}
|
package/storage.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** DevThink local store: mirrors credential-free workbench records into a physical SQLite database. */
|
|
2
|
+
import type { DevThinkPaths } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
type Statement = { run: (...values: unknown[]) => unknown };
|
|
5
|
+
type Database = { exec: (sql: string) => unknown; prepare: (sql: string) => Statement; close: () => void };
|
|
6
|
+
type DatabaseConstructor = new (path: string) => Database;
|
|
7
|
+
|
|
8
|
+
const sqliteModule = await import(process.versions.bun ? "bun:sqlite" : "node:sqlite");
|
|
9
|
+
const DatabaseDriver = (process.versions.bun ? sqliteModule.Database : sqliteModule.DatabaseSync) as unknown as DatabaseConstructor;
|
|
10
|
+
|
|
11
|
+
export type StoredWorkspace = { id: string; title: string; createdAt: string; updatedAt: string };
|
|
12
|
+
export type StoredTab = { id: string; sessionId: string; workspaceId: string; label: string; provider?: string; sectionId: string; createdAt: string; updatedAt: string };
|
|
13
|
+
export type StoredMessage = { id: string; sessionId: string; workspaceId: string; tabId: string; sectionId: string; role: string; content: string; createdAt: string };
|
|
14
|
+
export type StoredSession = { id: string; workspaceId: string; title: string; mode: string; model?: string; provider?: string; activeTabId: string; createdAt: string; updatedAt: string; tabs: StoredTab[]; messages: StoredMessage[] };
|
|
15
|
+
|
|
16
|
+
function initialize(database: Database): void {
|
|
17
|
+
database.exec(`
|
|
18
|
+
PRAGMA journal_mode = WAL;
|
|
19
|
+
PRAGMA foreign_keys = ON;
|
|
20
|
+
CREATE TABLE IF NOT EXISTS workspaces (id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, title TEXT NOT NULL, mode TEXT NOT NULL, model TEXT, provider TEXT, active_tab_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS tabs (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, workspace_id TEXT NOT NULL, label TEXT NOT NULL, provider TEXT, section_id TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, workspace_id TEXT NOT NULL, tab_id TEXT NOT NULL, section_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS sessions_workspace_updated ON sessions(workspace_id, updated_at DESC);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS tabs_session_updated ON tabs(session_id, updated_at DESC);
|
|
26
|
+
CREATE INDEX IF NOT EXISTS messages_session_created ON messages(session_id, created_at ASC);
|
|
27
|
+
`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Executes a local SQLite transaction without allowing database failures to erase JSON-compatible records. */
|
|
31
|
+
export function mirrorSession(paths: DevThinkPaths, workspace: StoredWorkspace, session: StoredSession): void {
|
|
32
|
+
let database: Database | undefined;
|
|
33
|
+
try {
|
|
34
|
+
database = new DatabaseDriver(paths.database);
|
|
35
|
+
initialize(database);
|
|
36
|
+
database.exec("BEGIN IMMEDIATE");
|
|
37
|
+
database.prepare("INSERT INTO workspaces (id, title, created_at, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at").run(workspace.id, workspace.title, workspace.createdAt, workspace.updatedAt);
|
|
38
|
+
database.prepare("INSERT INTO sessions (id, workspace_id, title, mode, model, provider, active_tab_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET workspace_id = excluded.workspace_id, title = excluded.title, mode = excluded.mode, model = excluded.model, provider = excluded.provider, active_tab_id = excluded.active_tab_id, updated_at = excluded.updated_at").run(session.id, session.workspaceId, session.title, session.mode, session.model ?? null, session.provider ?? null, session.activeTabId, session.createdAt, session.updatedAt);
|
|
39
|
+
database.prepare("DELETE FROM tabs WHERE session_id = ?").run(session.id);
|
|
40
|
+
database.prepare("DELETE FROM messages WHERE session_id = ?").run(session.id);
|
|
41
|
+
const addTab = database.prepare("INSERT INTO tabs (id, session_id, workspace_id, label, provider, section_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
42
|
+
const addMessage = database.prepare("INSERT INTO messages (id, session_id, workspace_id, tab_id, section_id, role, content, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
43
|
+
for (const tab of session.tabs) addTab.run(tab.id, tab.sessionId, tab.workspaceId, tab.label, tab.provider ?? null, tab.sectionId, tab.createdAt, tab.updatedAt);
|
|
44
|
+
for (const message of session.messages) addMessage.run(message.id, message.sessionId, message.workspaceId, message.tabId, message.sectionId, message.role, message.content, message.createdAt);
|
|
45
|
+
database.exec("COMMIT");
|
|
46
|
+
} catch {
|
|
47
|
+
try { database?.exec("ROLLBACK"); } catch { /* JSON compatibility record remains the authoritative fallback. */ }
|
|
48
|
+
} finally {
|
|
49
|
+
database?.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
package/stream.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export type ChatEvent =
|
|
2
|
+
| { type: "start"; provider: string; model: string }
|
|
3
|
+
| { type: "text"; text: string }
|
|
4
|
+
| { type: "reasoning"; text: string }
|
|
5
|
+
| { type: "tool"; name: string; input: unknown }
|
|
6
|
+
| { type: "error"; message: string }
|
|
7
|
+
| { type: "finish"; reason?: string };
|
|
8
|
+
|
|
9
|
+
export type StreamOptions = {
|
|
10
|
+
provider: string;
|
|
11
|
+
model: string;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function decodeJson(value: string): Record<string, unknown> | null {
|
|
16
|
+
try {
|
|
17
|
+
const parsed: unknown = JSON.parse(value);
|
|
18
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function extractSseFrames(buffer: { value: string }, incoming: string): string[] {
|
|
25
|
+
buffer.value += incoming;
|
|
26
|
+
const frames = buffer.value.split(/\r?\n\r?\n/);
|
|
27
|
+
buffer.value = frames.pop() || "";
|
|
28
|
+
return frames;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractData(frame: string): string | null {
|
|
32
|
+
const lines = frame.split(/\r?\n/).filter((line) => line.startsWith("data:"));
|
|
33
|
+
if (lines.length === 0) return null;
|
|
34
|
+
const value = lines.map((line) => line.slice(5).trimStart()).join("\n").trim();
|
|
35
|
+
return value && value !== "[DONE]" ? value : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function textAt(value: unknown): string {
|
|
39
|
+
return typeof value === "string" ? value : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeOpenAi(frame: Record<string, unknown>): ChatEvent[] {
|
|
43
|
+
const choices = Array.isArray(frame.choices) ? frame.choices : [];
|
|
44
|
+
const choice = choices[0] as Record<string, unknown> | undefined;
|
|
45
|
+
const delta = (choice?.delta || {}) as Record<string, unknown>;
|
|
46
|
+
const events: ChatEvent[] = [];
|
|
47
|
+
const reasoning = textAt(delta.reasoning_content || delta.reasoning);
|
|
48
|
+
const text = textAt(delta.content);
|
|
49
|
+
if (reasoning) events.push({ type: "reasoning", text: reasoning });
|
|
50
|
+
if (text) events.push({ type: "text", text });
|
|
51
|
+
const finish = choice?.finish_reason;
|
|
52
|
+
if (finish) events.push({ type: "finish", reason: textAt(finish) });
|
|
53
|
+
return events;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeAnthropic(frame: Record<string, unknown>): ChatEvent[] {
|
|
57
|
+
const type = textAt(frame.type);
|
|
58
|
+
if (type === "content_block_delta") {
|
|
59
|
+
const delta = (frame.delta || {}) as Record<string, unknown>;
|
|
60
|
+
const text = textAt(delta.text);
|
|
61
|
+
return text ? [{ type: "text", text }] : [];
|
|
62
|
+
}
|
|
63
|
+
if (type === "message_stop") return [{ type: "finish", reason: "stop" }];
|
|
64
|
+
if (type === "error") {
|
|
65
|
+
const error = (frame.error || {}) as Record<string, unknown>;
|
|
66
|
+
return [{ type: "error", message: textAt(error.message) || "Anthropic returned an error." }];
|
|
67
|
+
}
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeGoogle(frame: Record<string, unknown>): ChatEvent[] {
|
|
72
|
+
const candidates = Array.isArray(frame.candidates) ? frame.candidates : [];
|
|
73
|
+
const content = ((candidates[0] as Record<string, unknown> | undefined)?.content || {}) as Record<string, unknown>;
|
|
74
|
+
const parts = Array.isArray(content.parts) ? content.parts : [];
|
|
75
|
+
const events: ChatEvent[] = [];
|
|
76
|
+
for (const part of parts) {
|
|
77
|
+
const item = part as Record<string, unknown>;
|
|
78
|
+
const text = textAt(item.text);
|
|
79
|
+
if (text) events.push({ type: "text", text });
|
|
80
|
+
}
|
|
81
|
+
const finish = textAt((candidates[0] as Record<string, unknown> | undefined)?.finishReason);
|
|
82
|
+
if (finish) events.push({ type: "finish", reason: finish });
|
|
83
|
+
return events;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function normalizeFrame(provider: string, frame: Record<string, unknown>): ChatEvent[] {
|
|
87
|
+
const lower = provider.toLowerCase();
|
|
88
|
+
if (lower === "anthropic") return normalizeAnthropic(frame);
|
|
89
|
+
if (lower === "google") return normalizeGoogle(frame);
|
|
90
|
+
const data = (frame.data || frame) as Record<string, unknown>;
|
|
91
|
+
if (data !== frame && textAt(data.delta_content)) return [{ type: "text", text: textAt(data.delta_content) }];
|
|
92
|
+
if (data !== frame && textAt(data.reasoning_content)) return [{ type: "reasoning", text: textAt(data.reasoning_content) }];
|
|
93
|
+
if (data.error || frame.error) return [{ type: "error", message: textAt(data.error || frame.error) || "The provider returned an error." }];
|
|
94
|
+
return normalizeOpenAi(frame);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function* parseEventStream(response: Response, options: StreamOptions): AsyncGenerator<ChatEvent> {
|
|
98
|
+
if (!response.body) throw new Error("The provider returned an empty stream.");
|
|
99
|
+
yield { type: "start", provider: options.provider, model: options.model };
|
|
100
|
+
const reader = response.body.getReader();
|
|
101
|
+
const decoder = new TextDecoder();
|
|
102
|
+
const buffer = { value: "" };
|
|
103
|
+
try {
|
|
104
|
+
while (true) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done) break;
|
|
107
|
+
for (const frame of extractSseFrames(buffer, decoder.decode(value, { stream: true }))) {
|
|
108
|
+
const payload = extractData(frame);
|
|
109
|
+
if (!payload) continue;
|
|
110
|
+
const parsed = decodeJson(payload);
|
|
111
|
+
if (!parsed) continue;
|
|
112
|
+
for (const event of normalizeFrame(options.provider, parsed)) yield event;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
reader.releaseLock();
|
|
117
|
+
}
|
|
118
|
+
yield { type: "finish", reason: "stop" };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function collectEvents(events: AsyncIterable<ChatEvent>): Promise<{ text: string; reasoning: string }> {
|
|
122
|
+
let text = "";
|
|
123
|
+
let reasoning = "";
|
|
124
|
+
for await (const event of events) {
|
|
125
|
+
if (event.type === "text") text += event.text;
|
|
126
|
+
if (event.type === "reasoning") reasoning += event.text;
|
|
127
|
+
if (event.type === "error") throw new Error(event.message);
|
|
128
|
+
}
|
|
129
|
+
return { text, reasoning };
|
|
130
|
+
}
|
package/sync.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getIdentity } from "./identity.ts";
|
|
2
|
+
import { listSessions, loadWorkspace, type Session, type Workspace } from "./session.ts";
|
|
3
|
+
import { isSecureRemoteEndpoint } from "./compatibility.ts";
|
|
4
|
+
import type { DevThinkConfig, DevThinkPaths } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
export type SyncSnapshot = { version: 1; userId: string; deviceId: string; exportedAt: string; workspaces: Workspace[]; sessions: Session[] };
|
|
7
|
+
export type RemoteSyncStatus = { enabled: boolean; endpoint?: string; ready: boolean; reason?: string };
|
|
8
|
+
|
|
9
|
+
export function exportLocalSnapshot(paths: DevThinkPaths): SyncSnapshot {
|
|
10
|
+
const identity = getIdentity(paths);
|
|
11
|
+
const sessions = listSessions(paths);
|
|
12
|
+
const workspaces = [...new Set(sessions.map((session) => session.workspaceId))].flatMap((workspaceId) => {
|
|
13
|
+
const workspace = loadWorkspace(paths, workspaceId);
|
|
14
|
+
return workspace ? [workspace] : [];
|
|
15
|
+
});
|
|
16
|
+
return { version: 1, userId: identity.userId, deviceId: identity.deviceId, exportedAt: new Date().toISOString(), workspaces, sessions };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function remoteSyncStatus(config: DevThinkConfig): RemoteSyncStatus {
|
|
20
|
+
const remote = config.web?.remoteSync;
|
|
21
|
+
if (!remote?.enabled) return { enabled: false, ready: false, reason: "Remote synchronization is disabled; local pairing remains available." };
|
|
22
|
+
if (!remote.endpoint) return { enabled: true, ready: false, reason: "Set web.remoteSync.endpoint to a user-operated HTTPS API that implements the DevThink sync contract." };
|
|
23
|
+
if (!isSecureRemoteEndpoint(remote.endpoint)) {
|
|
24
|
+
return { enabled: true, endpoint: remote.endpoint, ready: false, reason: "Remote synchronization endpoint is not a valid HTTPS URL." };
|
|
25
|
+
}
|
|
26
|
+
return { enabled: true, endpoint: new URL(remote.endpoint).toString(), ready: true };
|
|
27
|
+
}
|
package/ui.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ChatEvent } from "./stream.ts";
|
|
2
|
+
|
|
3
|
+
export const colors = {
|
|
4
|
+
background: "\u001b[38;5;236m",
|
|
5
|
+
panel: "\u001b[38;5;238m",
|
|
6
|
+
text: "\u001b[38;5;252m",
|
|
7
|
+
muted: "\u001b[38;5;245m",
|
|
8
|
+
cyan: "\u001b[38;5;116m",
|
|
9
|
+
blue: "\u001b[38;5;110m",
|
|
10
|
+
green: "\u001b[38;5;151m",
|
|
11
|
+
red: "\u001b[38;5;167m",
|
|
12
|
+
yellow: "\u001b[38;5;222m",
|
|
13
|
+
reset: "\u001b[0m",
|
|
14
|
+
bold: "\u001b[1m",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"];
|
|
18
|
+
|
|
19
|
+
/** Canonical DevThink ANSI mark transcribed from docs/logo.md. */
|
|
20
|
+
const canonicalMark = String.raw`
|
|
21
|
+
+
|
|
22
|
+
+
|
|
23
|
+
-+
|
|
24
|
+
++
|
|
25
|
+
++
|
|
26
|
+
+++
|
|
27
|
+
-++
|
|
28
|
+
++++-
|
|
29
|
+
+++++++++
|
|
30
|
+
+-+++++++++++++--
|
|
31
|
+
--++++++-++++++++++++
|
|
32
|
+
+++++++-+ +++ +--++++++-
|
|
33
|
+
-+++++++-+ +++ -+-+++++++
|
|
34
|
+
++++++++++ -+++ --++++++++
|
|
35
|
+
++++++++-+ ++++ --+++++++-
|
|
36
|
+
++++++++++ ++++ --+++++++-
|
|
37
|
+
---++++++- -++++ -++++++--+
|
|
38
|
+
++++++++-- +++++ ++++++++--
|
|
39
|
+
+--+++++-- +++++ +++++++---
|
|
40
|
+
++++++++- +++++ +-+++++++
|
|
41
|
+
-+++++-- -++++- --++++++
|
|
42
|
+
-+++++- +++++- +++++++
|
|
43
|
+
-++++++ -+++++- +++++++
|
|
44
|
+
-++++++ ++++++- +++++++
|
|
45
|
+
-++++++ +++++++- +++++++
|
|
46
|
+
-++++++ ++++++++- +++++++
|
|
47
|
+
-++++++ -++++++++++-+ +++++++
|
|
48
|
+
-++++++ -+++++++++++++- +++++++
|
|
49
|
+
-++++++ -+++++++++++++++++ +++++++
|
|
50
|
+
-++++++ -++++++++++++++++++++ +++++++
|
|
51
|
+
-++++++ -++++++++++++++++++++++ +++++++
|
|
52
|
+
-++++++ -+++++++++++++++++++++++++- +++++++
|
|
53
|
+
-++++++ +++++++++++++++++-+--++++++++-- +++++++
|
|
54
|
+
-++++++ +++++++++++++++ -+++++++++ +++++++
|
|
55
|
+
-++++++ +++++++++++-++ --++++++-+ +-++++
|
|
56
|
+
-++++++ ++++++++++-+- +--++++-- --++
|
|
57
|
+
-++++++ +--++++++++++ ---++++-+ +-
|
|
58
|
+
-++++++ ++-++++++++ ++++++++
|
|
59
|
+
-++++++ -+++++-+-- +-++-+
|
|
60
|
+
-+++++++++++++ -++++++
|
|
61
|
+
-++++++++ +-+++-+++-
|
|
62
|
+
-+++++++++++ ++++++++- +---+
|
|
63
|
+
--++-+ -++++++-++ ---++++++++ +-++
|
|
64
|
+
++++ +-+++++++ -++++++-+ ++
|
|
65
|
+
-++ ++++++++-- ++++++++-+- +
|
|
66
|
+
+-++++++-+ --++++++++
|
|
67
|
+
++-+++++++ ++++++++-+
|
|
68
|
+
+-++++++++ --++++++++
|
|
69
|
+
++++++++++ +--++++++-
|
|
70
|
+
--+++++++- ++++++++++
|
|
71
|
+
-+++++++-++++--++++++++
|
|
72
|
+
+-++++++++++++++-
|
|
73
|
+
--++++++++-
|
|
74
|
+
+-++-`;
|
|
75
|
+
|
|
76
|
+
function color(code: string, value: string): string {
|
|
77
|
+
return `${code}${value}${colors.reset}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pad(value: string, width: number): string {
|
|
81
|
+
return value.length >= width ? value.slice(0, width) : value + " ".repeat(width - value.length);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function banner(version: string): string {
|
|
85
|
+
const mark = canonicalMark.split("\n").map((line) => color(colors.yellow, line)).join("\n");
|
|
86
|
+
const title = color(colors.yellow + colors.bold, "DEVTHINK");
|
|
87
|
+
return [mark, `${title} ${color(colors.muted, `v${version}`)}`, color(colors.muted, "Embedded gateway · terminal workbench · user-owned configuration")].join("\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function box(title: string, content: string, width = 76): string {
|
|
91
|
+
const innerWidth = Math.max(20, width - 4);
|
|
92
|
+
const top = `╭─ ${color(colors.cyan, title)} ${"─".repeat(Math.max(0, innerWidth - title.length - 3))}╮`;
|
|
93
|
+
const body = content.split("\n").map((line) => `│ ${pad(line, innerWidth)} │`);
|
|
94
|
+
return [top, ...body, `╰${"─".repeat(width - 2)}╯`].join("\n");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function statusBar(provider: string, model: string, mode: string): string {
|
|
98
|
+
return color(colors.muted, `[${provider || "not configured"}] [${model || "not configured"}] [${mode}]`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function spinnerFrame(index: number, message: string): string {
|
|
102
|
+
return `${color(colors.cyan, frames[index % frames.length])} ${message}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function formatEvent(event: ChatEvent): string {
|
|
106
|
+
if (event.type === "text") return event.text;
|
|
107
|
+
if (event.type === "reasoning") return color(colors.muted, event.text);
|
|
108
|
+
if (event.type === "error") return color(colors.red, event.message);
|
|
109
|
+
if (event.type === "finish") return "";
|
|
110
|
+
if (event.type === "start") return color(colors.muted, `Connected to ${event.provider} / ${event.model}`);
|
|
111
|
+
return color(colors.blue, `${event.name}: ${JSON.stringify(event.input)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function suggestions(input: string, values: string[], limit = 8): string[] {
|
|
115
|
+
const query = input.trim().toLowerCase();
|
|
116
|
+
if (!query) return values.slice(0, limit);
|
|
117
|
+
return values.filter((value) => value.toLowerCase().includes(query)).slice(0, limit);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function formatConfig(config: Record<string, unknown>): string {
|
|
121
|
+
return Object.entries(config).map(([key, value]) => `${color(colors.muted, pad(key, 20))} ${String(value)}`).join("\n");
|
|
122
|
+
}
|