@venturewild/workspace 0.6.18 → 0.6.20

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@venturewild/workspace",
3
- "version": "0.6.18",
3
+ "version": "0.6.20",
4
4
  "description": "Claude Code Web — Replit/Lovable-style chat-first browser UI that wraps the AI agent already installed on your machine.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -73,6 +73,65 @@ export const SEED_THEMES = [
73
73
  outcomeScore: 0.74, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
74
74
  rating: { stars: 4, count: 6 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
75
75
  },
76
+ // Framework themes (item 5) — the structural engine in action. Mirror of
77
+ // FRAMEWORK_THEMES in web/src/theme.js (keep in sync). Each carries a `structure`
78
+ // bundle the client's normalizeStructure validates before applying.
79
+ {
80
+ id: 'theme-neo-brutalism', kind: 'theme', source: 'theme',
81
+ title: 'Neo-Brutalism', pitch: 'Raw & bold — square corners, thick black borders, hard shadows.',
82
+ summary: 'A loud, high-contrast look: zero radius, 3px black borders, hard offset shadows, a bold grotesk.',
83
+ tags: ['light', 'bold', 'brutalist', 'framework'],
84
+ producer: { name: 'VentureWild', handle: 'venturewild', kind: 'vendor' },
85
+ theme: {
86
+ mode: 'light', accent: '#2b59ff',
87
+ tokens: { bg: '#fffdf5', surface: '#ffffff', text: '#0a0a0a', textMuted: '#3a3a3a', border: '#0a0a0a', canvas1: '#ffe600', canvas2: '#ff90e8', canvas3: '#9df6c4' },
88
+ structure: { radius: 0, borderWidth: 3, shadow: 'hard', fontFamily: 'grotesk', fontWeight: 'bold', surfaceStyle: 'flat' },
89
+ },
90
+ outcomeScore: 0.88, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
91
+ rating: { stars: 5, count: 19 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
92
+ },
93
+ {
94
+ id: 'theme-glassmorphism', kind: 'theme', source: 'theme',
95
+ title: 'Glassmorphism', pitch: 'Frosted glass — translucent, blurred panels over a vivid gradient.',
96
+ summary: 'Soft, modern, translucent: blurred surfaces float over a violet-cyan gradient, big radius, gentle shadow.',
97
+ tags: ['light', 'glass', 'modern', 'framework'],
98
+ producer: { name: 'VentureWild', handle: 'venturewild', kind: 'vendor' },
99
+ theme: {
100
+ mode: 'light', accent: '#7c3aed',
101
+ tokens: { bg: '#f3f0ff', surface: '#ffffff', text: '#1a1530', textMuted: '#6b6486', border: '#e5e0f5', canvas1: '#a78bfa', canvas2: '#67e8f9', canvas3: '#f0abfc' },
102
+ structure: { radius: 22, borderWidth: 1, shadow: 'soft', fontFamily: 'inter', fontWeight: 'medium', surfaceStyle: 'glass' },
103
+ },
104
+ outcomeScore: 0.86, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
105
+ rating: { stars: 5, count: 22 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
106
+ },
107
+ {
108
+ id: 'theme-swiss', kind: 'theme', source: 'theme',
109
+ title: 'Swiss / Minimal', pitch: 'International Typographic — flat, tight, no shadow, a single red.',
110
+ summary: 'Classic Swiss style: a clean grotesk, flat surfaces, near-zero radius, no shadow, one decisive red accent.',
111
+ tags: ['light', 'minimal', 'swiss', 'framework'],
112
+ producer: { name: 'VentureWild', handle: 'venturewild', kind: 'vendor' },
113
+ theme: {
114
+ mode: 'light', accent: '#e0322a',
115
+ tokens: { bg: '#ffffff', surface: '#ffffff', text: '#111111', textMuted: '#6b6b6b', border: '#e4e4e4', canvas1: '#f5f5f5', canvas2: '#fafafa', canvas3: '#ffffff' },
116
+ structure: { radius: 2, borderWidth: 1, shadow: 'none', fontFamily: 'grotesk', fontWeight: 'semibold', surfaceStyle: 'flat' },
117
+ },
118
+ outcomeScore: 0.83, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
119
+ rating: { stars: 5, count: 14 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
120
+ },
121
+ {
122
+ id: 'theme-retro-terminal', kind: 'theme', source: 'theme',
123
+ title: 'Retro-Terminal', pitch: 'Phosphor CRT — dark, monospace, a green accent that glows.',
124
+ summary: 'A green-phosphor terminal: near-black green-tinted background, monospace, glowing accent ring.',
125
+ tags: ['dark', 'mono', 'retro', 'framework'],
126
+ producer: { name: 'VentureWild', handle: 'venturewild', kind: 'vendor' },
127
+ theme: {
128
+ mode: 'dark', accent: '#2ee66b',
129
+ tokens: { bg: '#04140a', surface: '#0a1f12', text: '#9bf7b8', textMuted: '#4f9e6a', border: '#15402a', canvas1: '#04160c', canvas2: '#06200f', canvas3: '#020a05' },
130
+ structure: { radius: 0, borderWidth: 1, shadow: 'glow', fontFamily: 'mono', fontWeight: 'normal', surfaceStyle: 'flat' },
131
+ },
132
+ outcomeScore: 0.81, outcomeStats: { builds: 0, working: 0 }, safetyBadge: 'verified',
133
+ rating: { stars: 4, count: 12 }, reward: { model: 'one-time', unit: 'per apply', oneTimeValue: 1.5, perUseValue: 0 },
134
+ },
76
135
  ];
77
136
 
78
137
  // ~/.wild-workspace/bazaar — mirrors logpaths.globalDir() but kept dependency-free
@@ -37,6 +37,39 @@ export const TOKEN_KEYS = [
37
37
  'bg', 'bgElev', 'surface', 'border', 'text', 'textMuted', 'canvas1', 'canvas2', 'canvas3',
38
38
  ];
39
39
 
40
+ // Structural theme tokens (item 5, 2026-06-21) — the SHAPE half of the theme engine.
41
+ // SAME security boundary as the color tokens: numbers CLAMP to a safe range, enums and
42
+ // font ids are ALLOWLISTED — a stored structure can only ever be one of these values,
43
+ // never a raw CSS string. Keep in lockstep with theme.js (sanitizeStructure + the
44
+ // *_IDS / *_STACKS / *_VALUES tables that map an id to its hard-coded CSS).
45
+ export const SHADOW_IDS = ['none', 'soft', 'hard', 'glow'];
46
+ export const SURFACE_STYLE_IDS = ['flat', 'glass', 'paper'];
47
+ export const FONT_WEIGHT_IDS = ['normal', 'medium', 'semibold', 'bold'];
48
+ export const UI_FONT_IDS = ['system', 'inter', 'grotesk', 'serif', 'mono'];
49
+
50
+ function clampInt(v, lo, hi) {
51
+ const n = Math.round(Number(v));
52
+ if (!Number.isFinite(n)) return null;
53
+ return Math.max(lo, Math.min(hi, n));
54
+ }
55
+
56
+ // Validate a raw structure bundle to the allowlisted/clamped shape. Accepts a nested
57
+ // `structure` object OR flat fields on the raw theme (the MCP set_theme/publish_theme
58
+ // path passes flat input). Unknown values drop; out-of-range numbers clamp.
59
+ export function normalizeStructure(raw = {}) {
60
+ const r = raw && typeof raw === 'object' ? raw : {};
61
+ const out = {};
62
+ const radius = clampInt(r.radius, 0, 32);
63
+ if (radius !== null) out.radius = radius;
64
+ const bw = clampInt(r.borderWidth, 0, 6);
65
+ if (bw !== null) out.borderWidth = bw;
66
+ if (SHADOW_IDS.includes(r.shadow)) out.shadow = r.shadow;
67
+ if (UI_FONT_IDS.includes(r.fontFamily)) out.fontFamily = r.fontFamily;
68
+ if (FONT_WEIGHT_IDS.includes(r.fontWeight)) out.fontWeight = r.fontWeight;
69
+ if (SURFACE_STYLE_IDS.includes(r.surfaceStyle)) out.surfaceStyle = r.surfaceStyle;
70
+ return out;
71
+ }
72
+
40
73
  // Reading typography (chat "Aa" controls). The server only ever stores allowlisted
41
74
  // ids — never a raw font string — so a stored value can only ever be one the client
42
75
  // maps to a hard-coded font stack (no CSS injection). Keep in lockstep with
@@ -222,6 +255,8 @@ export function normalizeTheme(raw = {}) {
222
255
  if (hex) tokens[key] = hex;
223
256
  }
224
257
  theme.tokens = tokens;
258
+ // Structural tokens — nested `structure` wins, else read flat fields off the theme.
259
+ theme.structure = normalizeStructure(raw.structure || raw);
225
260
  return theme;
226
261
  }
227
262
 
@@ -407,6 +442,8 @@ export function createCanvas({ baseDir, personKey } = {}) {
407
442
  if (hex) theme.tokens[key] = hex;
408
443
  }
409
444
  }
445
+ // Structural tokens — allowlisted/clamped, follows the user across devices.
446
+ theme.structure = normalizeStructure(raw?.structure);
410
447
  ensureStateDir();
411
448
  try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
412
449
  return theme;
@@ -68,18 +68,57 @@ function nextStamp(sessions) {
68
68
  return Math.max(nowMs(), max + 1);
69
69
  }
70
70
 
71
+ // Per-session provider (items 13/14) — Claude or GLM, LOCKED at creation (resume
72
+ // tokens don't cross providers, so a chat is one provider for its life).
73
+ const PROVIDERS = ['claude', 'glm'];
74
+
71
75
  function sanitizeRec(s) {
72
76
  const id = safeId(s?.id);
73
77
  if (!id) return null;
74
78
  return {
75
79
  id,
76
80
  title: typeof s.title === 'string' && s.title.trim() ? s.title.trim().slice(0, TITLE_CAP) : null,
81
+ // 'auto' = server-derived from the conversation; 'manual' = the user renamed it
82
+ // (sticky — auto-naming never overwrites a manual title). Default 'auto'.
83
+ titleSource: s.titleSource === 'manual' ? 'manual' : 'auto',
84
+ provider: PROVIDERS.includes(s.provider) ? s.provider : 'claude',
77
85
  account: typeof s.account === 'string' && s.account ? s.account : 'local',
86
+ // Per-session context gauge (item 3) — the % of THIS session's provider window in
87
+ // use, persisted on turn completion so the sidebar shows it without a live turn.
88
+ contextPct: Number.isFinite(Number(s.contextPct)) ? Number(s.contextPct) : 0,
89
+ contextUsed: Number.isFinite(Number(s.contextUsed)) ? Number(s.contextUsed) : 0,
90
+ contextWindow: Number.isFinite(Number(s.contextWindow)) ? Number(s.contextWindow) : 0,
78
91
  createdAt: Number(s.createdAt) || nowMs(),
79
92
  lastActivityAt: Number(s.lastActivityAt) || Number(s.createdAt) || nowMs(),
80
93
  };
81
94
  }
82
95
 
96
+ // --- auto-naming + living summary (item 9) — derived from the conversation ---
97
+ // The old auto-naming "wasn't working"; this derives a stable title from the first
98
+ // user message and keeps a refreshing summary .md (the carry-the-gist handoff).
99
+ function userTexts(messages) {
100
+ return (Array.isArray(messages) ? messages : [])
101
+ .filter((m) => m && m.role === 'user' && typeof m.text === 'string' && m.text.trim())
102
+ .map((m) => m.text.trim().replace(/\s+/g, ' '));
103
+ }
104
+
105
+ export function deriveTitle(messages) {
106
+ const users = userTexts(messages);
107
+ if (!users.length) return null;
108
+ let t = users[0]
109
+ .replace(/^(can you|could you|please|i want to|i'd like to|i would like to|help me|let's|lets|i need to|how do i|how can i|how to)\s+/i, '');
110
+ t = t.charAt(0).toUpperCase() + t.slice(1);
111
+ return t.slice(0, 60) || null;
112
+ }
113
+
114
+ export function buildSummary(messages) {
115
+ const users = userTexts(messages);
116
+ if (!users.length) return null;
117
+ const lines = ['# Session summary', '', `**Gist:** ${users[0].slice(0, 280)}`, '', '## What was asked'];
118
+ for (const u of users.slice(0, 12)) lines.push(`- ${u.slice(0, 120)}`);
119
+ return `${lines.join('\n')}\n`;
120
+ }
121
+
83
122
  /**
84
123
  * Per-workspace session store. Construct from the workspace's dataDir (the
85
124
  * `.wild-workspace` folder) — `createChatSessions({ dataDir: workspaceFor(c).dataDir })`.
@@ -88,6 +127,7 @@ export function createChatSessions({ dataDir } = {}) {
88
127
  const dir = path.join(dataDir, 'sessions');
89
128
  const indexFile = path.join(dir, 'index.json');
90
129
  const msgFile = (id) => path.join(dir, `${id}.json`);
130
+ const summaryFile = (id) => path.join(dir, `${id}.summary.md`);
91
131
  // The Claude resume file for a session: Main → legacy un-suffixed; others suffixed.
92
132
  const resumeFile = (id) =>
93
133
  id === MAIN_SESSION_ID
@@ -136,8 +176,8 @@ export function createChatSessions({ dataDir } = {}) {
136
176
  writeIndex(sessions);
137
177
  }
138
178
 
139
- /** Create a new session. Returns the record. */
140
- function create({ title, account } = {}) {
179
+ /** Create a new session. Returns the record. `provider` is locked at creation. */
180
+ function create({ title, account, provider } = {}) {
141
181
  const sessions = readIndex();
142
182
  // Soft cap: evict the oldest non-Main session that has no stored messages.
143
183
  if (sessions.length >= MAX_SESSIONS) {
@@ -150,6 +190,8 @@ export function createChatSessions({ dataDir } = {}) {
150
190
  const rec = sanitizeRec({
151
191
  id: nanoid(10),
152
192
  title: title || null,
193
+ titleSource: title ? 'manual' : 'auto',
194
+ provider: provider || 'claude',
153
195
  account: account || 'local',
154
196
  createdAt: stamp,
155
197
  lastActivityAt: stamp,
@@ -159,6 +201,13 @@ export function createChatSessions({ dataDir } = {}) {
159
201
  return rec;
160
202
  }
161
203
 
204
+ /** One session record (or null). */
205
+ function get(id) {
206
+ const safe = safeId(id);
207
+ if (!safe) return null;
208
+ return readIndex().find((s) => s.id === safe) || null;
209
+ }
210
+
162
211
  function getMessages(id) {
163
212
  const safe = safeId(id);
164
213
  if (!safe) return [];
@@ -185,17 +234,30 @@ export function createChatSessions({ dataDir } = {}) {
185
234
  } catch {
186
235
  return { ok: false, count: 0, error: 'write_failed' };
187
236
  }
188
- // Upsert + touch the registry record.
237
+ // Refresh the living summary (item 9) — the carry-the-gist handoff .md.
238
+ const summary = buildSummary(capped);
239
+ if (summary) {
240
+ try {
241
+ const tmp = `${summaryFile(safe)}.${process.pid}.tmp`;
242
+ fs.writeFileSync(tmp, summary);
243
+ fs.renameSync(tmp, summaryFile(safe));
244
+ } catch { /* best-effort */ }
245
+ }
246
+ // Upsert + touch the registry record; auto-derive the title (unless manually set).
189
247
  const sessions = readIndex();
190
248
  const stamp = nextStamp(sessions);
191
249
  const i = findIndex(sessions, safe);
250
+ const autoTitle = deriveTitle(capped);
192
251
  if (i >= 0) {
193
- sessions[i] = { ...sessions[i], lastActivityAt: stamp };
252
+ const prev = sessions[i];
253
+ const title = prev.titleSource === 'manual' ? prev.title : (autoTitle || prev.title);
254
+ sessions[i] = { ...prev, title, lastActivityAt: stamp };
194
255
  } else {
195
256
  sessions.push(
196
257
  sanitizeRec({
197
258
  id: safe,
198
- title: safe === MAIN_SESSION_ID ? 'Main' : null,
259
+ title: safe === MAIN_SESSION_ID ? 'Main' : autoTitle,
260
+ titleSource: safe === MAIN_SESSION_ID ? 'manual' : 'auto',
199
261
  account: account || 'local',
200
262
  createdAt: stamp,
201
263
  lastActivityAt: stamp,
@@ -206,15 +268,42 @@ export function createChatSessions({ dataDir } = {}) {
206
268
  return { ok: true, count: capped.length };
207
269
  }
208
270
 
271
+ /** Read a session's living summary .md (or null). The fresh-chat handoff payload. */
272
+ function getSummary(id) {
273
+ const safe = safeId(id);
274
+ if (!safe) return null;
275
+ try { return fs.readFileSync(summaryFile(safe), 'utf8'); } catch { return null; }
276
+ }
277
+
278
+ /** Persist a session's context gauge after a turn (item 3). */
279
+ function setContext(id, { pct, used, window } = {}) {
280
+ const safe = safeId(id);
281
+ if (!safe) return;
282
+ const sessions = readIndex();
283
+ const i = findIndex(sessions, safe);
284
+ if (i < 0) return;
285
+ sessions[i] = {
286
+ ...sessions[i],
287
+ contextPct: Number.isFinite(Number(pct)) ? Math.max(0, Math.min(100, Number(pct))) : sessions[i].contextPct,
288
+ contextUsed: Number.isFinite(Number(used)) ? Number(used) : sessions[i].contextUsed,
289
+ contextWindow: Number.isFinite(Number(window)) ? Number(window) : sessions[i].contextWindow,
290
+ };
291
+ writeIndex(sessions);
292
+ }
293
+
209
294
  function rename(id, title) {
210
295
  const safe = safeId(id);
211
296
  if (!safe) return null;
212
297
  const sessions = readIndex();
213
298
  const i = findIndex(sessions, safe);
214
299
  if (i < 0) return null;
300
+ const clean = typeof title === 'string' && title.trim() ? title.trim().slice(0, TITLE_CAP) : null;
215
301
  sessions[i] = {
216
302
  ...sessions[i],
217
- title: typeof title === 'string' && title.trim() ? title.trim().slice(0, TITLE_CAP) : null,
303
+ title: clean,
304
+ // A manual rename is sticky — auto-naming won't overwrite it. Clearing the title
305
+ // (empty) hands naming back to the auto-deriver.
306
+ titleSource: clean ? 'manual' : 'auto',
218
307
  };
219
308
  writeIndex(sessions);
220
309
  return sessions[i];
@@ -229,7 +318,7 @@ export function createChatSessions({ dataDir } = {}) {
229
318
  if (i < 0) return false;
230
319
  sessions.splice(i, 1);
231
320
  writeIndex(sessions);
232
- for (const f of [msgFile(safe), resumeFile(safe)]) {
321
+ for (const f of [msgFile(safe), summaryFile(safe), resumeFile(safe)]) {
233
322
  try { fs.unlinkSync(f); } catch { /* already gone / read-only */ }
234
323
  }
235
324
  return true;
@@ -258,5 +347,5 @@ export function createChatSessions({ dataDir } = {}) {
258
347
  writeIndex(sessions);
259
348
  }
260
349
 
261
- return { dir, list, ensureMain, create, getMessages, putMessages, rename, remove, touch };
350
+ return { dir, list, get, ensureMain, create, getMessages, putMessages, getSummary, setContext, rename, remove, touch };
262
351
  }
@@ -225,7 +225,7 @@ export const DEFAULT_AGENTS = Object.freeze([
225
225
  id: 'glm',
226
226
  binary: 'glm',
227
227
  label: 'GLM (Z.AI)',
228
- description: 'GLM-4.6 via Z.AI',
228
+ description: 'GLM-5.1 via Z.AI',
229
229
  args: ['-p', '--permission-mode', 'bypassPermissions'],
230
230
  streamFormat: 'text',
231
231
  },