@wenathlan/devthink 1.1.13 → 1.1.15

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 CHANGED
@@ -1,4 +1,26 @@
1
1
  # DevThink release notes
2
+ ## 1.1.15 — Unified capabilities and Settings
3
+ ### Added
4
+ | Area | Addition |
5
+ | --- | --- |
6
+ | Capability matrix | A renderer-neutral matrix now maps every safe local action across CLI commands, Ink destinations, authenticated gateway routes and web pages. |
7
+ | Shared Settings page | `/settings` is a dedicated browser page equivalent to `devthink config settings`, `devthink config set` and the Ink Settings view. |
8
+ | Settings summary | The authenticated `GET /settings` route returns identity, pairing session count, preferences, active provider summary and local database ownership metadata without secrets or filesystem paths. |
9
+ | Same database client | The paired browser operates as a temporary client of the CLI-owned `devthink.db` for one public ID; it does not create an independent static database. |
10
+ | Settings domain | Pairing controls now belong to `web/settings/`, and the common control navigation includes Settings alongside Providers, Projects, Routes and Usage. |
11
+
12
+ ## 1.1.14 — Route and local identity refinement
13
+
14
+ ### Added
15
+
16
+ | Area | Addition |
17
+ | --- | --- |
18
+ | Portable Home route | Relative static builds preserve the Vite `./` base and resolve the Home route at `/` without treating `.` as a router prefix. |
19
+ | CLI-owned person | The browser now projects the authenticated CLI-owned public user and device identity after pairing instead of storing a second browser identity. |
20
+ | Shared public ID | `devthink identity --id <value>` and the paired web Settings panel update the same local public identifier, with compact 10–15 character validation. |
21
+ | Shared route index | Web and CLI route maps now list the authenticated `GET` and `PUT /identity` operations alongside preferences and workspace routes. |
22
+ | Documentation boundary | Workbench design and deployment notes moved from `/web` to `docs/`, leaving the static application tree free of planning documents. |
23
+
2
24
  ## 1.1.13 — Flat domain consolidation
3
25
 
4
26
  ### Added
@@ -11,6 +33,10 @@
11
33
  | Compact visual grammar | Historical workspace CSS overrides were replaced with one terminal workspace system for entry, tabs, rail, command bar, pairing, controls and error states. |
12
34
  | Control frame | Providers, projects, routes and usage share one explicitly named root control shell without a generic application directory. |
13
35
  | Preserved parity boundary | Web and Ink retain the same safe workspace destinations, settings and local gateway interactions; provider credentials remain CLI-owned. |
36
+ | Portable Home route | Relative Vite builds now retain `./` as a relative base and normalize it to the Home router base, so `/` resolves the first workbench screen in portable static artifacts. |
37
+ | Same local person | A paired browser reads the CLI-owned public user and device IDs through the authenticated gateway instead of creating a second browser profile. |
38
+ | Public ID controls | `devthink identity --id <value>` and the paired web Settings panel update the same local public ID, preserving compact workspace data and temporary browser sessions. |
39
+ | Documentation boundary | Workbench deployment and design records now live under `docs/`, leaving `/web` exclusively for browser application artifacts. |
14
40
 
15
41
  ## 1.1.12 — Page domains and tabbed interaction
16
42
 
package/devthink.ts CHANGED
@@ -8,7 +8,7 @@ import { createMemoryStore, memorySummary } from "./memory.ts";
8
8
  import { listModes, modePrompt, resolveMode } from "./modes.ts";
9
9
  import { listModels, listProviders, streamChat } from "./providers.ts";
10
10
  import { appendMessage, createSession, exportSession, listSessions, loadSession, type Session } from "./session.ts";
11
- import { createPairing, createPairingLink, getIdentity, pairingStatus, revokeBrowserSessions } from "./identity.ts";
11
+ import { createPairing, createPairingLink, getIdentity, pairingStatus, revokeBrowserSessions, setIdentityUserId } from "./identity.ts";
12
12
  import { readPreferences, savePreference } from "./storage.ts";
13
13
  import type { ChatEvent } from "./stream.ts";
14
14
  import { banner, box, colors, formatConfig, formatEvent, statusBar } from "./ui.ts";
@@ -69,12 +69,13 @@ function printHelp(): void {
69
69
  " modes List the 20 operational modes",
70
70
  " config [key] [value] Read or write local configuration",
71
71
  " config preferences Show shared workbench preferences",
72
+ " config settings Show shared identity, pairing, provider and database summary",
72
73
  " config set <key> <value> Set theme, railMode, or interfaceZoom",
73
74
  " auth login <provider> Save a user-provided official credential",
74
75
  " auth status Show redacted configured credential sources",
75
76
  " auth clear <provider> Remove a stored provider credential",
76
77
  " auth migrate Move legacy config credentials into auth.json",
77
- " identity Show local public user and device identifiers",
78
+ " identity [--id <value>] Show or set the local public user identifier",
78
79
  " pair create Create an automatic one-time link for the web workbench",
79
80
  " pair revoke Revoke current paired browser sessions",
80
81
  " sync status Show local snapshot and remote adapter readiness",
@@ -155,6 +156,17 @@ async function runChat(prompt: string, parsed: ParsedArgs, runtime: ReturnType<t
155
156
 
156
157
  async function handleConfig(parsed: ParsedArgs, runtime: ReturnType<typeof loadRuntime>): Promise<void> {
157
158
  const action = parsed.positional[1];
159
+ if (action === "settings") {
160
+ const identity = getIdentity(runtime.paths);
161
+ const sessions = listSessions(runtime.paths);
162
+ return console.log(JSON.stringify({
163
+ identity,
164
+ pairing: pairingStatus(runtime.paths),
165
+ preferences: Object.fromEntries(Object.entries(readPreferences(runtime.paths)).map(([key, preference]) => [key, preference.value])),
166
+ provider: { activeProvider: runtime.config.activeProvider || runtime.config.provider || undefined, activeModel: runtime.config.activeModel || runtime.config.model || undefined },
167
+ database: { ownerUserId: identity.userId, local: true, persistence: "cli-owned-sqlite", workspaces: new Set(sessions.map((session) => session.workspaceId)).size, sessions: sessions.length },
168
+ }, null, 2));
169
+ }
158
170
  if (action === "preferences") return console.log(JSON.stringify(Object.fromEntries(Object.entries(readPreferences(runtime.paths)).map(([key, preference]) => [key, preference.value])), null, 2));
159
171
  if (action === "set") {
160
172
  const key = parsed.positional[2];
@@ -212,13 +224,15 @@ async function handleAuth(parsed: ParsedArgs, runtime: ReturnType<typeof loadRun
212
224
  throw new Error("Supported auth actions: login, status, clear, migrate.");
213
225
  }
214
226
 
215
- function handleIdentity(runtime: ReturnType<typeof loadRuntime>): void {
216
- console.log(JSON.stringify({ identity: getIdentity(runtime.paths), pairing: pairingStatus(runtime.paths) }, null, 2));
227
+ function handleIdentity(parsed: ParsedArgs, runtime: ReturnType<typeof loadRuntime>): void {
228
+ const requestedUserId = stringFlag(parsed, "id");
229
+ const identity = requestedUserId ? setIdentityUserId(runtime.paths, requestedUserId) : getIdentity(runtime.paths);
230
+ console.log(JSON.stringify({ identity, pairing: pairingStatus(runtime.paths) }, null, 2));
217
231
  }
218
232
 
219
233
  function handlePairing(parsed: ParsedArgs, runtime: ReturnType<typeof loadRuntime>): void {
220
234
  const action = parsed.positional[1] || "create";
221
- if (action === "status") return handleIdentity(runtime);
235
+ if (action === "status") return handleIdentity(parsed, runtime);
222
236
  if (action === "revoke") return console.log(`Revoked ${revokeBrowserSessions(runtime.paths)} paired browser session(s).`);
223
237
  if (action !== "create") throw new Error("Supported pair actions: create, status, revoke.");
224
238
  const pairing = createPairing(runtime.paths);
@@ -278,7 +292,7 @@ function handleUsage(runtime: ReturnType<typeof loadRuntime>): void {
278
292
  }
279
293
 
280
294
  function handleRoutes(): void {
281
- console.log(["web: /", "web: /providers", "web: /projects", "web: /routes", "web: /usage", "web: /w/:workspaceId/s/:sessionId/t/:tabId/:sectionId", "gateway: GET /health", "gateway: GET /providers", "gateway: GET /workspaces", "gateway: GET /usage", "gateway: GET /preferences", "gateway: PATCH /preferences", "gateway: POST /sessions", "gateway: POST /chat"].join("\n"));
295
+ console.log(["web: /", "web: /providers", "web: /projects", "web: /routes", "web: /usage", "web: /settings", "web: /w/:workspaceId/s/:sessionId/t/:tabId/:sectionId", "gateway: GET /health", "gateway: GET /identity", "gateway: PUT /identity", "gateway: GET /settings", "gateway: GET /providers", "gateway: GET /workspaces", "gateway: GET /usage", "gateway: GET /preferences", "gateway: PATCH /preferences", "gateway: POST /sessions", "gateway: POST /chat"].join("\n"));
282
296
  }
283
297
 
284
298
  async function interactive(runtime: ReturnType<typeof loadRuntime>): Promise<void> {
@@ -351,7 +365,7 @@ export async function main(argv = process.argv.slice(2)): Promise<void> {
351
365
  if (command === "modes") return console.log(listModes().map((mode) => `${mode.id} ${mode.purpose}`).join("\n"));
352
366
  if (command === "config") return handleConfig(parsed, runtime);
353
367
  if (command === "auth") return handleAuth(parsed, runtime);
354
- if (command === "identity") return handleIdentity(runtime);
368
+ if (command === "identity") return handleIdentity(parsed, runtime);
355
369
  if (command === "pair") return handlePairing(parsed, runtime);
356
370
  if (command === "sync") return handleSync(parsed, runtime);
357
371
  if (command === "gateway") return handleGateway(runtime);
package/identity.ts CHANGED
@@ -13,6 +13,12 @@ const codeLength = 8;
13
13
  const pairingLifetimeMs = 5 * 60 * 1000;
14
14
  const browserSessionLifetimeMs = 15 * 60 * 1000;
15
15
 
16
+ export function normalizePublicUserId(value: string): string {
17
+ const userId = value.trim().toLowerCase();
18
+ if (!/^[a-z][a-z0-9]{9,14}$/.test(userId)) throw new Error("Public user ID must contain 10-15 lowercase letters or numbers and start with a letter.");
19
+ return userId;
20
+ }
21
+
16
22
  function digest(value: string): string {
17
23
  return createHash("sha256").update(value).digest("hex");
18
24
  }
@@ -44,6 +50,20 @@ export function getIdentity(paths: DevThinkPaths): DevThinkIdentity {
44
50
  return identity;
45
51
  }
46
52
 
53
+ /** Updates the public local-person identifier and migrates active local pairing records. */
54
+ export function setIdentityUserId(paths: DevThinkPaths, requestedUserId: string): DevThinkIdentity {
55
+ const current = getIdentity(paths);
56
+ const userId = normalizePublicUserId(requestedUserId);
57
+ if (current.userId === userId) return current;
58
+ const next = { ...current, userId };
59
+ privateWrite(paths.identity, next);
60
+ const store = readStore(paths);
61
+ for (const record of store.pairings) if (record.userId === current.userId) record.userId = userId;
62
+ for (const session of store.sessions) if (session.userId === current.userId) session.userId = userId;
63
+ writeStore(paths, store);
64
+ return next;
65
+ }
66
+
47
67
  function readStore(paths: DevThinkPaths): PairingStore {
48
68
  try {
49
69
  if (existsSync(paths.pairings)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/devthink",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/wenathlan/devthink"
package/server.ts CHANGED
@@ -3,7 +3,7 @@ import { randomInt } from "node:crypto";
3
3
  import { isAllowedOrigin } from "./compatibility.ts";
4
4
  import { listModels, listProviders, streamChat, type ChatMessage } from "./providers.ts";
5
5
  import { ensurePaths, resolvePaths, saveConfig, type DevThinkConfig, type DevThinkPaths } from "./config.ts";
6
- import { consumePairing, getIdentity, pairingStatus, revokeBrowserSessions, verifyBrowserSession } from "./identity.ts";
6
+ import { consumePairing, getIdentity, pairingStatus, revokeBrowserSessions, setIdentityUserId, verifyBrowserSession } from "./identity.ts";
7
7
  import { appendMessage, createSession, createTab, listSessions, loadSession, loadWorkspace, updateTab, type SessionSection } from "./session.ts";
8
8
  import { readPreferences, savePreference } from "./storage.ts";
9
9
 
@@ -138,6 +138,12 @@ async function route(request: IncomingMessage, response: ServerResponse, config:
138
138
  const browserSession = origin ? verifyBrowserSession(paths, bearerToken(request)) : undefined;
139
139
  if (origin && !browserSession) return writeJson(response, 401, { error: "A current browser pairing token is required." }, origin);
140
140
  if (request.method === "GET" && url.pathname === "/identity") return writeJson(response, 200, { identity: getIdentity(paths), pairing: pairingStatus(paths) }, origin);
141
+ if (request.method === "PUT" && url.pathname === "/identity") {
142
+ const body = await readBody(request);
143
+ if (typeof body.userId !== "string") return writeJson(response, 400, { error: "userId is required." }, origin);
144
+ try { return writeJson(response, 200, { identity: setIdentityUserId(paths, body.userId), pairing: pairingStatus(paths) }, origin); }
145
+ catch (error) { return writeJson(response, 400, { error: error instanceof Error ? error.message : "Identity update was rejected." }, origin); }
146
+ }
141
147
  if (request.method === "POST" && url.pathname === "/pairings/revoke") return writeJson(response, 200, { revoked: revokeBrowserSessions(paths) }, origin);
142
148
  if (request.method === "GET" && url.pathname === "/sessions") return writeJson(response, 200, { sessions: listSessions(paths) }, origin);
143
149
  if (request.method === "GET" && url.pathname === "/workspaces") {
@@ -157,6 +163,17 @@ async function route(request: IncomingMessage, response: ServerResponse, config:
157
163
  if (request.method === "GET" && url.pathname === "/preferences") {
158
164
  return writeJson(response, 200, { preferences: Object.fromEntries(Object.entries(readPreferences(paths)).map(([key, preference]) => [key, preference.value])) }, origin);
159
165
  }
166
+ if (request.method === "GET" && url.pathname === "/settings") {
167
+ const identity = getIdentity(paths);
168
+ const sessions = listSessions(paths);
169
+ return writeJson(response, 200, {
170
+ identity,
171
+ pairing: pairingStatus(paths),
172
+ preferences: Object.fromEntries(Object.entries(readPreferences(paths)).map(([key, preference]) => [key, preference.value])),
173
+ provider: { activeProvider: config.activeProvider || config.provider || undefined, activeModel: config.activeModel || config.model || undefined },
174
+ database: { ownerUserId: identity.userId, local: true, persistence: "cli-owned-sqlite", workspaces: new Set(sessions.map((session) => session.workspaceId)).size, sessions: sessions.length },
175
+ }, origin);
176
+ }
160
177
  if (request.method === "PATCH" && url.pathname === "/preferences") {
161
178
  const body = await readBody(request);
162
179
  const key = typeof body.key === "string" ? body.key : "";
package/terminal-ui.tsx CHANGED
@@ -1,9 +1,10 @@
1
- /** Design: DevThink v1.1.13 — Ink shares the compact local workspace grammar, destinations, settings and command language with the flat React workbench. */
1
+ /** Design: DevThink v1.1.15 — Ink shares the compact local workspace grammar, identity, destinations, settings and command language with the paired React workbench. */
2
2
  import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
3
3
  import { useMemo, useState } from "react";
4
4
  import { listProviders } from "./providers.ts";
5
5
  import { listSessions, type Session } from "./session.ts";
6
6
  import { readPreferences, savePreference } from "./storage.ts";
7
+ import { getIdentity, pairingStatus } from "./identity.ts";
7
8
  import type { DevThinkConfig, DevThinkPaths } from "./config.ts";
8
9
  import type { ChatEvent } from "./stream.ts";
9
10
  import { isWorkspaceDestination, workspaceDestination, workspaceDestinations, type WorkspaceDestination } from "./workspace.ts";
@@ -20,9 +21,9 @@ function clip(value: string, width: number): string {
20
21
  return value.length > width ? `${value.slice(0, Math.max(0, width - 1))}…` : value;
21
22
  }
22
23
 
23
- function TerminalEntry({ draft, provider, width }: { draft: string; provider: string; width: number }) {
24
+ function TerminalEntry({ draft, provider, userId, width }: { draft: string; provider: string; userId: string; width: number }) {
24
25
  return <Box flexDirection="column" width="100%" height="100%" minHeight={20} paddingX={2} paddingY={1} justifyContent="space-between">
25
- <Box justifyContent="space-between"><Text color="#807970">✦ DEVTHINK</Text><Text color="#8ab4f8">LOCAL-FIRST WORKBENCH</Text></Box>
26
+ <Box justifyContent="space-between"><Text color="#807970">✦ DEVTHINK</Text><Text color="#8ab4f8">{userId}</Text></Box>
26
27
  <Box flexDirection="column" alignItems="center"><Box flexDirection="column" alignItems="center">{entryArt.map((line, index) => <Text key={`${line}-${index}`} color="#382c23">{line}</Text>)}</Box><Box marginTop={-1}><Text color="#ff5f00" bold>DEVTHINK</Text></Box><Box marginTop={1}><Text color="#746f66">begin with one intention</Text></Box><Box marginTop={2}><Text color="#282724">┌{"─".repeat(Math.max(16, width - 2))}┐</Text></Box><Text color="#e8e1d7">│ <Text color="#ff5f00">›_ </Text><Text color={draft ? "#e8e1d7" : "#746f66"}>{clip(draft || "Ask anything…", Math.max(12, width - 22))}</Text><Text color="#8ab4f8"> {provider}</Text><Text color="#ff5f00"> [open]</Text> │</Text><Text color="#282724">└{"─".repeat(Math.max(16, width - 2))}┘</Text></Box>
27
28
  <Text color="#746f66">enter opens a local workspace · esc exits</Text>
28
29
  </Box>;
@@ -45,7 +46,10 @@ function viewRows(view: WorkspaceDestination, runtime: Runtime, session: Session
45
46
  if (view === "routes") return ["GET /health", "GET /providers", "GET /workspaces", "GET /usage", "GET /preferences", "PATCH /preferences", "POST /sessions", "POST /chat"];
46
47
  if (view === "settings") {
47
48
  const preferences = Object.fromEntries(Object.entries(readPreferences(runtime.paths)).map(([key, preference]) => [key, preference.value]));
48
- return [`theme ${preferences.theme || "dark"}`, `railMode ${preferences.railMode || "auto"}`, `interfaceZoom ${preferences.interfaceZoom || "100"}`, "edit: /settings set theme dark|light", "edit: /settings set railMode always|auto|off", "edit: /settings set interfaceZoom 80-150", "credentials remain in ~/.config/devthink/auth.json"];
49
+ const identity = getIdentity(runtime.paths);
50
+ const pairing = pairingStatus(runtime.paths);
51
+ const sessions = listSessions(runtime.paths);
52
+ return [`userId ${identity.userId}`, `deviceId ${identity.deviceId}`, `browser sessions ${pairing.activeSessions}`, `database cli-owned-sqlite · ${new Set(sessions.map((session) => session.workspaceId)).size} workspaces · ${sessions.length} sessions`, `provider ${runtime.config.activeProvider || runtime.config.provider || "not configured"}`, `theme ${preferences.theme || "dark"}`, `railMode ${preferences.railMode || "auto"}`, `interfaceZoom ${preferences.interfaceZoom || "100"}`, "inspect all: devthink config settings", "edit identity: devthink identity --id <10-15 lowercase chars>", "edit: /settings set theme dark|light", "edit: /settings set railMode always|auto|off", "edit: /settings set interfaceZoom 80-150", "credentials remain in ~/.config/devthink/auth.json"];
49
53
  }
50
54
  if (session) return [`workspace ${session.workspaceId}`, `session ${session.id}`, `tab ${session.activeTabId}`];
51
55
  return ["Describe the next feature, bug or piece of work below."];
@@ -63,6 +67,7 @@ function TerminalWorkspace({ runtime, execute, version }: { runtime: Runtime; ex
63
67
  const [pending, setPending] = useState(false);
64
68
  const [preferenceRevision, setPreferenceRevision] = useState(0);
65
69
  const provider = runtime.config.activeProvider || runtime.config.provider || "local";
70
+ const identity = getIdentity(runtime.paths);
66
71
  const model = runtime.config.activeModel || runtime.config.model || "not configured";
67
72
  const compact = columns < 100;
68
73
  const terminalWidth = Math.max(38, columns - (compact ? 2 : 6));
@@ -127,10 +132,10 @@ function TerminalWorkspace({ runtime, execute, version }: { runtime: Runtime; ex
127
132
  if (!key.ctrl && !key.meta && input) setDraft((current) => current + input);
128
133
  });
129
134
 
130
- if (!entered) return <TerminalEntry draft={draft} provider={provider} width={terminalWidth} />;
135
+ if (!entered) return <TerminalEntry draft={draft} provider={provider} userId={identity.userId} width={terminalWidth} />;
131
136
 
132
137
  return <Box flexDirection="column" width="100%" height="100%" minHeight={20} paddingX={compact ? 1 : 3} paddingY={1}>
133
- <Box justifyContent="space-between"><Text color="#a19a91">✦ DEVTHINK <Text color="#746f66">/ {workspaceDestination(view).label}</Text></Text><Text color="#8ab4f8">◉ {provider.toUpperCase()}</Text></Box>
138
+ <Box justifyContent="space-between"><Text color="#a19a91">✦ DEVTHINK <Text color="#746f66">/ {workspaceDestination(view).label}</Text></Text><Text color="#8ab4f8">◉ {identity.userId}</Text></Box>
134
139
  <Text color="#282724">{"─".repeat(terminalWidth)}</Text>
135
140
  <Text color="#746f66">{clip(tabLine, terminalWidth)}</Text>
136
141
  <Box marginTop={1}><Text color="#e8e1d7">{workspaceDestination(view).glyph} {workspaceDestination(view).label.toUpperCase()} <Text color="#746f66">· {clip(model, 22)}</Text></Text></Box>