@jhizzard/termdeck 0.4.0 → 0.4.2
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/README.md +4 -2
- package/package.json +1 -1
- package/packages/cli/src/forge.js +262 -0
- package/packages/cli/src/index.js +24 -0
- package/packages/client/public/app.js +204 -6
- package/packages/client/public/style.css +298 -2
- package/packages/server/src/forge-prompt.js +265 -0
- package/packages/server/src/index.js +115 -2
- package/packages/server/src/skill-installer.js +166 -0
package/README.md
CHANGED
|
@@ -18,6 +18,8 @@ Ninety seconds, one command. Node 18+ is all you need — prebuilt binaries mean
|
|
|
18
18
|
|
|
19
19
|
This is **Tier 1**. Works immediately, fully local, no accounts, no credentials, no database. You get the full dashboard — 7 grid layouts, 8 themes, per-panel metadata overlays, terminal switcher, reply button, status logs, session history in local SQLite. **Flashback is silent at this tier** because there's no memory store to query.
|
|
20
20
|
|
|
21
|
+
First-time user? The **config** button in the toolbar shows what's set up and what's next — click it for a live view of each tier's status with guided next steps.
|
|
22
|
+
|
|
21
23
|
Enabling Flashback takes **one additional 15-minute setup step** — see Tier 2 below. The rest of this README explains what you get, how it works, and how to go deeper.
|
|
22
24
|
|
|
23
25
|
---
|
|
@@ -137,7 +139,7 @@ Restart Claude Code. Six MCP tools appear: `memory_remember`, `memory_recall`, `
|
|
|
137
139
|
|
|
138
140
|
### Tier 3 — Add Rumen for async learning
|
|
139
141
|
|
|
140
|
-
Rumen is a separate npm package — `@jhizzard/rumen@0.4.
|
|
142
|
+
Rumen is a separate npm package — `@jhizzard/rumen@0.4.2` — that ships as a Supabase Edge Function designed to run on a 15-minute `pg_cron` schedule. It's the async reflection layer over Mnestra: it reads recent session memories, cross-references them with your entire historical corpus via hybrid search, synthesizes insights via Claude Haiku, and writes the results back into `rumen_insights` (a new table alongside Mnestra's `memory_items`). TermDeck's Flashback and Claude Code's `memory_recall` both automatically benefit because insights flow back into the same database.
|
|
141
143
|
|
|
142
144
|
**Rumen is live.** First full-kickstart run against a production Mnestra store on 2026-04-15 19:47 UTC: **111 sessions processed, 111 insights generated** in one pass. Insights surfaced patterns like "the error detection regex in Flashback misses `No such file or directory` — same class of blind spot as X" and "Practice sessions exist as a separate model but frontend components were built and never wired into the schedule view." The cognitive loop is closed.
|
|
143
145
|
|
|
@@ -161,7 +163,7 @@ Honest limits, stated upfront so the skeptic has nothing to chase:
|
|
|
161
163
|
- **Not a replacement for reading docs.** It's the shortest path to a memory you already wrote. If the memory isn't there, the feature does nothing.
|
|
162
164
|
- **Not fully local by default.** Tier 2+ reaches out to Supabase for storage and OpenAI for embeddings. Tier 1 is fully local. A fully-local Tier 2 (local Postgres + local embeddings) is on the roadmap.
|
|
163
165
|
- **Not free forever.** Tier 2+ pays OpenAI fractions of a cent per memory for embeddings. Self-hosted embeddings via Ollama are on the roadmap.
|
|
164
|
-
- **Not proven at scale.** v0.4.
|
|
166
|
+
- **Not proven at scale.** v0.4.2, validated against 3,527 memories in one developer's production store. First full Rumen kickstart on 2026-04-15 processed 111 sessions into 111 insights in one pass. No multi-user data yet. Bug reports and issues welcome.
|
|
165
167
|
|
|
166
168
|
---
|
|
167
169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// `termdeck forge` — Tier 5 SkillForge: autonomous skill generation from
|
|
4
|
+
// Mnestra memories. This file is the CLI surface only; the Opus prompt
|
|
5
|
+
// template (T2) and skill installer (T3) land in packages/server/src/.
|
|
6
|
+
//
|
|
7
|
+
// Sprint 20 scope (T1):
|
|
8
|
+
// 1. Parse flags (--dry-run, --yes, --max-cost, --min-confidence)
|
|
9
|
+
// 2. Connect to Mnestra /healthz and read the memory count
|
|
10
|
+
// 3. Project cost based on memory count × avg tokens × Opus pricing
|
|
11
|
+
// 4. Prompt for confirmation (skip with --yes or --dry-run)
|
|
12
|
+
//
|
|
13
|
+
// Steps 5–7 (Opus call, skill parsing, install) print a "Coming in v0.5"
|
|
14
|
+
// stub so the command exits cleanly for anyone who tries it early.
|
|
15
|
+
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
// Opus pricing (per million tokens) — keep in sync with
|
|
20
|
+
// https://www.anthropic.com/pricing when it changes.
|
|
21
|
+
const OPUS_PRICE_INPUT_PER_M = 15;
|
|
22
|
+
const OPUS_PRICE_OUTPUT_PER_M = 75;
|
|
23
|
+
// Each memory expands to ~200 input tokens once formatted into the forge
|
|
24
|
+
// prompt. Output is roughly 20% of input (skills are compact markdown).
|
|
25
|
+
const AVG_TOKENS_PER_MEMORY = 200;
|
|
26
|
+
const OUTPUT_TO_INPUT_RATIO = 0.2;
|
|
27
|
+
|
|
28
|
+
const HELP = [
|
|
29
|
+
'',
|
|
30
|
+
'TermDeck SkillForge (experimental)',
|
|
31
|
+
'',
|
|
32
|
+
'Usage: termdeck forge [flags]',
|
|
33
|
+
'',
|
|
34
|
+
'Flags:',
|
|
35
|
+
' --help Print this message and exit',
|
|
36
|
+
' --dry-run Show the cost projection and exit without prompting',
|
|
37
|
+
' --yes Skip the confirmation prompt (implies you accept the cost)',
|
|
38
|
+
' --max-cost <usd> Abort if projected cost exceeds this dollar amount',
|
|
39
|
+
' --min-confidence Minimum confidence score (0.0–1.0) for generated skills',
|
|
40
|
+
'',
|
|
41
|
+
'What this does (Sprint 20 preview):',
|
|
42
|
+
' 1. Reads memory count from Mnestra',
|
|
43
|
+
' 2. Projects the Opus cost for generating skills from those memories',
|
|
44
|
+
' 3. Asks for confirmation',
|
|
45
|
+
' 4. (v0.5) Calls Opus, parses skills, installs to ~/.claude/skills/',
|
|
46
|
+
''
|
|
47
|
+
].join('\n');
|
|
48
|
+
|
|
49
|
+
function parseFlags(argv) {
|
|
50
|
+
const out = {
|
|
51
|
+
help: false,
|
|
52
|
+
dryRun: false,
|
|
53
|
+
yes: false,
|
|
54
|
+
maxCost: null,
|
|
55
|
+
minConfidence: null
|
|
56
|
+
};
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (a === '--help' || a === '-h') {
|
|
60
|
+
out.help = true;
|
|
61
|
+
} else if (a === '--dry-run') {
|
|
62
|
+
out.dryRun = true;
|
|
63
|
+
} else if (a === '--yes' || a === '-y') {
|
|
64
|
+
out.yes = true;
|
|
65
|
+
} else if (a === '--max-cost' && argv[i + 1]) {
|
|
66
|
+
const n = Number(argv[i + 1]);
|
|
67
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
68
|
+
throw new Error(`--max-cost must be a non-negative number, got: ${argv[i + 1]}`);
|
|
69
|
+
}
|
|
70
|
+
out.maxCost = n;
|
|
71
|
+
i++;
|
|
72
|
+
} else if (a === '--min-confidence' && argv[i + 1]) {
|
|
73
|
+
const n = Number(argv[i + 1]);
|
|
74
|
+
if (!Number.isFinite(n) || n < 0 || n > 1) {
|
|
75
|
+
throw new Error(`--min-confidence must be between 0 and 1, got: ${argv[i + 1]}`);
|
|
76
|
+
}
|
|
77
|
+
out.minConfidence = n;
|
|
78
|
+
i++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function httpGet(url, timeoutMs) {
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
|
87
|
+
if (res.statusCode !== 200) {
|
|
88
|
+
res.resume();
|
|
89
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let body = '';
|
|
93
|
+
res.setEncoding('utf8');
|
|
94
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
95
|
+
res.on('end', () => resolve(body));
|
|
96
|
+
});
|
|
97
|
+
req.on('error', reject);
|
|
98
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Mnestra's /healthz returns one of several shapes depending on version; we
|
|
103
|
+
// probe all the documented keys and return the first numeric match. The same
|
|
104
|
+
// extraction logic lives in preflight.js — keep them in sync if the response
|
|
105
|
+
// format changes.
|
|
106
|
+
function extractMemoryCount(data) {
|
|
107
|
+
if (!data || typeof data !== 'object') return null;
|
|
108
|
+
const candidates = [
|
|
109
|
+
data.store && data.store.rows,
|
|
110
|
+
data.total,
|
|
111
|
+
data.memories,
|
|
112
|
+
data.count
|
|
113
|
+
];
|
|
114
|
+
for (const c of candidates) {
|
|
115
|
+
if (c == null) continue;
|
|
116
|
+
const n = Number(c);
|
|
117
|
+
if (Number.isFinite(n)) return n;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function fetchMemoryCount(config) {
|
|
123
|
+
const rag = (config && config.rag) || {};
|
|
124
|
+
const baseUrl = rag.mnestraWebhookUrl
|
|
125
|
+
? rag.mnestraWebhookUrl.replace(/\/mnestra\/?$/, '')
|
|
126
|
+
: 'http://localhost:37778';
|
|
127
|
+
const url = `${baseUrl}/healthz`;
|
|
128
|
+
const body = await httpGet(url, 3000);
|
|
129
|
+
let data;
|
|
130
|
+
try { data = JSON.parse(body); } catch (err) {
|
|
131
|
+
throw new Error(`Mnestra /healthz returned non-JSON: ${err.message}`);
|
|
132
|
+
}
|
|
133
|
+
const count = extractMemoryCount(data);
|
|
134
|
+
if (count == null) {
|
|
135
|
+
throw new Error(`Mnestra reachable at ${url} but no memory count in response`);
|
|
136
|
+
}
|
|
137
|
+
return { count, url };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function projectCost(memoryCount) {
|
|
141
|
+
const inputTokens = memoryCount * AVG_TOKENS_PER_MEMORY;
|
|
142
|
+
const outputTokens = Math.round(inputTokens * OUTPUT_TO_INPUT_RATIO);
|
|
143
|
+
const inputCost = (inputTokens / 1_000_000) * OPUS_PRICE_INPUT_PER_M;
|
|
144
|
+
const outputCost = (outputTokens / 1_000_000) * OPUS_PRICE_OUTPUT_PER_M;
|
|
145
|
+
return {
|
|
146
|
+
inputTokens,
|
|
147
|
+
outputTokens,
|
|
148
|
+
totalTokens: inputTokens + outputTokens,
|
|
149
|
+
inputCost,
|
|
150
|
+
outputCost,
|
|
151
|
+
totalCost: inputCost + outputCost
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatUSD(n) {
|
|
156
|
+
return `$${n.toFixed(2)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function formatTokens(n) {
|
|
160
|
+
return Number(n).toLocaleString();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function printProjection(count, mnestraUrl, cost, flags) {
|
|
164
|
+
const dim = '\x1b[2m';
|
|
165
|
+
const bold = '\x1b[1m';
|
|
166
|
+
const reset = '\x1b[0m';
|
|
167
|
+
process.stdout.write('\n');
|
|
168
|
+
process.stdout.write(` ${bold}TermDeck SkillForge (experimental)${reset}\n`);
|
|
169
|
+
process.stdout.write(` ${dim}Mnestra: ${mnestraUrl}${reset}\n\n`);
|
|
170
|
+
process.stdout.write(` Memories to analyze: ${bold}${formatTokens(count)}${reset}\n`);
|
|
171
|
+
process.stdout.write(` Estimated input: ${formatTokens(cost.inputTokens)} tokens (${formatUSD(cost.inputCost)})\n`);
|
|
172
|
+
process.stdout.write(` Estimated output: ${formatTokens(cost.outputTokens)} tokens (${formatUSD(cost.outputCost)})\n`);
|
|
173
|
+
process.stdout.write(` ${bold}Estimated total: ${formatUSD(cost.totalCost)}${reset}\n`);
|
|
174
|
+
if (flags.minConfidence != null) {
|
|
175
|
+
process.stdout.write(` ${dim}Min confidence: ${flags.minConfidence}${reset}\n`);
|
|
176
|
+
}
|
|
177
|
+
if (flags.maxCost != null) {
|
|
178
|
+
process.stdout.write(` ${dim}Max cost cap: ${formatUSD(flags.maxCost)}${reset}\n`);
|
|
179
|
+
}
|
|
180
|
+
process.stdout.write('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function askYesNo(question) {
|
|
184
|
+
// Lazy-require to avoid pulling readline until we actually prompt. Mirrors
|
|
185
|
+
// the pattern used by init-mnestra.js.
|
|
186
|
+
const setupDir = path.join(__dirname, '..', '..', 'server', 'src', 'setup');
|
|
187
|
+
const { prompts } = require(setupDir);
|
|
188
|
+
try {
|
|
189
|
+
return await prompts.confirm(question, { defaultYes: false });
|
|
190
|
+
} finally {
|
|
191
|
+
prompts.closeRl();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function forge(argv) {
|
|
196
|
+
let flags;
|
|
197
|
+
try {
|
|
198
|
+
flags = parseFlags(argv || []);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
process.stderr.write(`[forge] ${err.message}\n`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (flags.help) {
|
|
205
|
+
process.stdout.write(HELP);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Load config lazily — loading it triggers the "[config] Loaded from …"
|
|
210
|
+
// banner, which we only want when the command is actually running.
|
|
211
|
+
const { loadConfig } = require(path.join(__dirname, '..', '..', 'server', 'src', 'config.js'));
|
|
212
|
+
const config = loadConfig();
|
|
213
|
+
|
|
214
|
+
let memoryInfo;
|
|
215
|
+
try {
|
|
216
|
+
memoryInfo = await fetchMemoryCount(config);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
process.stderr.write(`[forge] Could not reach Mnestra: ${err.message}\n`);
|
|
219
|
+
process.stderr.write(`[forge] Start Mnestra with \`mnestra serve\` and retry.\n`);
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (memoryInfo.count === 0) {
|
|
224
|
+
process.stderr.write(`[forge] Mnestra has 0 memories — run \`mnestra ingest\` before forging skills.\n`);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const cost = projectCost(memoryInfo.count);
|
|
229
|
+
printProjection(memoryInfo.count, memoryInfo.url, cost, flags);
|
|
230
|
+
|
|
231
|
+
if (flags.maxCost != null && cost.totalCost > flags.maxCost) {
|
|
232
|
+
process.stderr.write(`[forge] Projected cost ${formatUSD(cost.totalCost)} exceeds --max-cost ${formatUSD(flags.maxCost)}. Aborting.\n`);
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (flags.dryRun) {
|
|
237
|
+
process.stdout.write('[forge] --dry-run: stopping before Opus call.\n');
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!flags.yes) {
|
|
242
|
+
const ok = await askYesNo(` Proceed and spend ~${formatUSD(cost.totalCost)} on Opus?`);
|
|
243
|
+
if (!ok) {
|
|
244
|
+
process.stdout.write('[forge] Aborted by user.\n');
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Steps 4–7 are deliberately stubbed for Sprint 20 — the Opus call, skill
|
|
250
|
+
// parsing, and installer land in Sprint 21 (T2 + T3 ship the building
|
|
251
|
+
// blocks).
|
|
252
|
+
process.stdout.write('\n');
|
|
253
|
+
process.stdout.write('[forge] Skill generation coming in v0.5.\n');
|
|
254
|
+
process.stdout.write('[forge] - Opus call: wired in Sprint 21 (T2 forge-prompt.js)\n');
|
|
255
|
+
process.stdout.write('[forge] - Skill installer: wired in Sprint 21 (T3 skill-installer.js)\n');
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = forge;
|
|
260
|
+
module.exports.parseFlags = parseFlags;
|
|
261
|
+
module.exports.projectCost = projectCost;
|
|
262
|
+
module.exports.extractMemoryCount = extractMemoryCount;
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// `--mnestra` / `init-mnestra.js` together.
|
|
13
13
|
|
|
14
14
|
const path = require('path');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
15
17
|
const { execSync } = require('child_process');
|
|
16
18
|
|
|
17
19
|
// Parse CLI args
|
|
@@ -48,6 +50,18 @@ if (args[0] === 'init') {
|
|
|
48
50
|
process.exit(1);
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
// `termdeck forge` — Sprint 20 SkillForge preview. Autonomously generates
|
|
54
|
+
// Claude Code skills from Mnestra memories. Lazy-loaded so the launcher
|
|
55
|
+
// startup path stays unaffected.
|
|
56
|
+
if (args[0] === 'forge') {
|
|
57
|
+
const forge = require(path.join(__dirname, 'forge.js'));
|
|
58
|
+
forge(args.slice(1)).then((code) => process.exit(code || 0)).catch((err) => {
|
|
59
|
+
console.error('[cli] forge failed:', err && err.stack || err);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
const flags = {};
|
|
52
66
|
for (let i = 0; i < args.length; i++) {
|
|
53
67
|
if (args[i] === '--port' && args[i + 1]) {
|
|
@@ -68,6 +82,7 @@ for (let i = 0; i < args.length; i++) {
|
|
|
68
82
|
termdeck --session-logs Write per-session markdown logs to ~/.termdeck/sessions/
|
|
69
83
|
termdeck init --mnestra Configure Tier 2 memory (Supabase + Mnestra)
|
|
70
84
|
termdeck init --rumen Deploy Tier 3 async learning (Rumen)
|
|
85
|
+
termdeck forge Generate Claude skills from memories (experimental)
|
|
71
86
|
|
|
72
87
|
Keyboard shortcuts (in browser):
|
|
73
88
|
Ctrl+Shift+N Focus prompt bar
|
|
@@ -94,6 +109,11 @@ if (flags.sessionLogs) {
|
|
|
94
109
|
process.env.TERMDECK_SESSION_LOGS = '1';
|
|
95
110
|
}
|
|
96
111
|
|
|
112
|
+
// First-run detection (Sprint 19 T3): surface a one-line hint pointing at
|
|
113
|
+
// the setup wizard when no config.yaml exists yet. Check happens before
|
|
114
|
+
// loadConfig() so the message reflects on-disk state, not defaults.
|
|
115
|
+
const firstRun = !fs.existsSync(path.join(os.homedir(), '.termdeck', 'config.yaml'));
|
|
116
|
+
|
|
97
117
|
const config = loadConfig();
|
|
98
118
|
if (flags.port) config.port = flags.port;
|
|
99
119
|
if (flags.sessionLogs) {
|
|
@@ -137,6 +157,10 @@ server.listen(port, host, async () => {
|
|
|
137
157
|
╚══════════════════════════════════════╝
|
|
138
158
|
`);
|
|
139
159
|
|
|
160
|
+
if (firstRun) {
|
|
161
|
+
console.log(" First run detected. Open http://localhost:3000 and click 'config' to set up.\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
140
164
|
// Run preflight health checks (non-blocking — warn but don't prevent startup)
|
|
141
165
|
runPreflight(config).then((result) => {
|
|
142
166
|
printHealthBanner(result);
|
|
@@ -80,6 +80,10 @@
|
|
|
80
80
|
setTimeout(() => { if (!tourState.active) startTour(); }, 1200);
|
|
81
81
|
}
|
|
82
82
|
} catch {}
|
|
83
|
+
|
|
84
|
+
// Sprint 19 T2: auto-open setup wizard if /api/setup reports firstRun.
|
|
85
|
+
// Silent-fail if the endpoint isn't available yet (T1 not merged).
|
|
86
|
+
maybeAutoOpenSetupWizard();
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
// ===== Create Terminal Panel =====
|
|
@@ -2419,6 +2423,203 @@
|
|
|
2419
2423
|
return html;
|
|
2420
2424
|
}
|
|
2421
2425
|
|
|
2426
|
+
// ===== Setup Wizard (Sprint 19 T2) =====
|
|
2427
|
+
// Progressive-disclosure modal that shows TermDeck's 4 configuration tiers
|
|
2428
|
+
// and their live status. Detection only — does not write config files.
|
|
2429
|
+
const SETUP_TIERS = [
|
|
2430
|
+
{
|
|
2431
|
+
id: '1',
|
|
2432
|
+
name: 'Tier 1 — TermDeck core',
|
|
2433
|
+
desc: 'Local terminal multiplexer running in your browser.',
|
|
2434
|
+
commands: []
|
|
2435
|
+
},
|
|
2436
|
+
{
|
|
2437
|
+
id: '2',
|
|
2438
|
+
name: 'Tier 2 — Mnestra RAG',
|
|
2439
|
+
desc: 'Persistent cross-session memory backed by Postgres + pgvector.',
|
|
2440
|
+
commands: ['termdeck init --mnestra']
|
|
2441
|
+
},
|
|
2442
|
+
{
|
|
2443
|
+
id: '3',
|
|
2444
|
+
name: 'Tier 3 — Rumen learning loop',
|
|
2445
|
+
desc: 'Async insight extraction and morning briefings (Supabase Edge Function).',
|
|
2446
|
+
commands: ['termdeck init --rumen']
|
|
2447
|
+
},
|
|
2448
|
+
{
|
|
2449
|
+
id: '4',
|
|
2450
|
+
name: 'Tier 4 — Projects',
|
|
2451
|
+
desc: 'Named project roots so you can launch with "cc <name>" shorthand.',
|
|
2452
|
+
commands: ['Click the + button in the prompt bar, or edit ~/.termdeck/config.yaml']
|
|
2453
|
+
}
|
|
2454
|
+
];
|
|
2455
|
+
|
|
2456
|
+
let setupModalOpen = false;
|
|
2457
|
+
|
|
2458
|
+
function ensureSetupModal() {
|
|
2459
|
+
if (document.getElementById('setupModal')) return;
|
|
2460
|
+
const modal = document.createElement('div');
|
|
2461
|
+
modal.className = 'setup-modal';
|
|
2462
|
+
modal.id = 'setupModal';
|
|
2463
|
+
modal.setAttribute('role', 'dialog');
|
|
2464
|
+
modal.setAttribute('aria-modal', 'true');
|
|
2465
|
+
modal.setAttribute('aria-labelledby', 'setupTitle');
|
|
2466
|
+
modal.innerHTML = `
|
|
2467
|
+
<div class="setup-backdrop" id="setupBackdrop"></div>
|
|
2468
|
+
<div class="setup-card">
|
|
2469
|
+
<header class="setup-header">
|
|
2470
|
+
<div>
|
|
2471
|
+
<h3 id="setupTitle">Setup wizard</h3>
|
|
2472
|
+
<p class="setup-subtitle" id="setupSubtitle">Checking status…</p>
|
|
2473
|
+
</div>
|
|
2474
|
+
<button type="button" class="setup-close" id="setupClose" aria-label="Close">×</button>
|
|
2475
|
+
</header>
|
|
2476
|
+
<div class="setup-body">
|
|
2477
|
+
<div class="setup-tiers" id="setupTiers">
|
|
2478
|
+
<div class="setup-loading">Checking tier status…</div>
|
|
2479
|
+
</div>
|
|
2480
|
+
</div>
|
|
2481
|
+
<footer class="setup-footer">
|
|
2482
|
+
<div class="setup-hint">
|
|
2483
|
+
Edit <code>~/.termdeck/config.yaml</code> and <code>~/.termdeck/secrets.env</code>, then re-check.
|
|
2484
|
+
</div>
|
|
2485
|
+
<div class="setup-actions">
|
|
2486
|
+
<button type="button" class="setup-recheck" id="setupRecheck">re-check</button>
|
|
2487
|
+
<button type="button" class="setup-done" id="setupDone">done</button>
|
|
2488
|
+
</div>
|
|
2489
|
+
</footer>
|
|
2490
|
+
</div>
|
|
2491
|
+
`;
|
|
2492
|
+
document.body.appendChild(modal);
|
|
2493
|
+
|
|
2494
|
+
document.getElementById('setupBackdrop').addEventListener('click', closeSetupModal);
|
|
2495
|
+
document.getElementById('setupClose').addEventListener('click', closeSetupModal);
|
|
2496
|
+
document.getElementById('setupDone').addEventListener('click', closeSetupModal);
|
|
2497
|
+
document.getElementById('setupRecheck').addEventListener('click', refreshSetupStatus);
|
|
2498
|
+
modal.addEventListener('keydown', (e) => {
|
|
2499
|
+
if (e.key === 'Escape') { e.preventDefault(); closeSetupModal(); }
|
|
2500
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
async function openSetupModal() {
|
|
2504
|
+
ensureSetupModal();
|
|
2505
|
+
document.getElementById('setupModal').classList.add('open');
|
|
2506
|
+
setupModalOpen = true;
|
|
2507
|
+
await refreshSetupStatus();
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function closeSetupModal() {
|
|
2511
|
+
const m = document.getElementById('setupModal');
|
|
2512
|
+
if (m) m.classList.remove('open');
|
|
2513
|
+
setupModalOpen = false;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
async function refreshSetupStatus() {
|
|
2517
|
+
const tiersEl = document.getElementById('setupTiers');
|
|
2518
|
+
const subtitle = document.getElementById('setupSubtitle');
|
|
2519
|
+
const recheckBtn = document.getElementById('setupRecheck');
|
|
2520
|
+
if (!tiersEl) return;
|
|
2521
|
+
tiersEl.innerHTML = '<div class="setup-loading">Checking tier status…</div>';
|
|
2522
|
+
if (subtitle) subtitle.textContent = 'Checking status…';
|
|
2523
|
+
if (recheckBtn) { recheckBtn.disabled = true; recheckBtn.textContent = 're-checking…'; }
|
|
2524
|
+
try {
|
|
2525
|
+
const data = await api('GET', '/api/setup');
|
|
2526
|
+
renderSetupTiers(data);
|
|
2527
|
+
const cur = Number(data.tier) || 1;
|
|
2528
|
+
if (subtitle) {
|
|
2529
|
+
subtitle.textContent = cur >= 4
|
|
2530
|
+
? 'All tiers configured — you are good to go.'
|
|
2531
|
+
: `Current tier: ${cur} of 4. Install the next tier below to unlock more features.`;
|
|
2532
|
+
}
|
|
2533
|
+
} catch (err) {
|
|
2534
|
+
tiersEl.innerHTML = `<div class="setup-error">
|
|
2535
|
+
Failed to load setup status: ${escapeHtml(err && err.message ? err.message : String(err))}.<br>
|
|
2536
|
+
Make sure the server is reachable and supports <code>GET /api/setup</code>.
|
|
2537
|
+
</div>`;
|
|
2538
|
+
if (subtitle) subtitle.textContent = 'Error checking status.';
|
|
2539
|
+
} finally {
|
|
2540
|
+
if (recheckBtn) { recheckBtn.disabled = false; recheckBtn.textContent = 're-check'; }
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
function renderSetupTiers(data) {
|
|
2545
|
+
const tiersEl = document.getElementById('setupTiers');
|
|
2546
|
+
if (!tiersEl) return;
|
|
2547
|
+
const tiers = (data && data.tiers) || {};
|
|
2548
|
+
const currentTier = Number(data && data.tier) || 1;
|
|
2549
|
+
|
|
2550
|
+
const html = SETUP_TIERS.map((tier, idx) => {
|
|
2551
|
+
const info = tiers[tier.id] || { status: 'not_configured', detail: 'Unknown' };
|
|
2552
|
+
const status = info.status || 'not_configured';
|
|
2553
|
+
const detail = info.detail || '';
|
|
2554
|
+
const badgeLabel = status === 'active'
|
|
2555
|
+
? 'active'
|
|
2556
|
+
: status === 'partial' ? 'partial' : 'not configured';
|
|
2557
|
+
const isCurrent = Number(tier.id) === currentTier + 1 && status !== 'active';
|
|
2558
|
+
const cmds = (status === 'active' || tier.commands.length === 0)
|
|
2559
|
+
? ''
|
|
2560
|
+
: `<div class="setup-cmds">${tier.commands.map((c) => {
|
|
2561
|
+
const copyable = /^termdeck\s/.test(c);
|
|
2562
|
+
return `<div class="setup-cmd">
|
|
2563
|
+
<code>${escapeHtml(c)}</code>
|
|
2564
|
+
${copyable ? `<button type="button" class="setup-copy" data-copy="${escapeHtml(c)}">copy</button>` : ''}
|
|
2565
|
+
</div>`;
|
|
2566
|
+
}).join('')}</div>`;
|
|
2567
|
+
|
|
2568
|
+
return `
|
|
2569
|
+
<div class="setup-tier setup-tier-${status}${isCurrent ? ' setup-tier-next' : ''}">
|
|
2570
|
+
<div class="setup-tier-rail" aria-hidden="true">
|
|
2571
|
+
<span class="setup-tier-dot"></span>
|
|
2572
|
+
${idx < SETUP_TIERS.length - 1 ? '<span class="setup-tier-line"></span>' : ''}
|
|
2573
|
+
</div>
|
|
2574
|
+
<div class="setup-tier-body">
|
|
2575
|
+
<div class="setup-tier-head">
|
|
2576
|
+
<span class="setup-tier-name">${escapeHtml(tier.name)}</span>
|
|
2577
|
+
<span class="setup-tier-status setup-tier-status-${status}">${escapeHtml(badgeLabel)}</span>
|
|
2578
|
+
</div>
|
|
2579
|
+
<div class="setup-tier-desc">${escapeHtml(tier.desc)}</div>
|
|
2580
|
+
${detail ? `<div class="setup-tier-detail">${escapeHtml(detail)}</div>` : ''}
|
|
2581
|
+
${cmds}
|
|
2582
|
+
</div>
|
|
2583
|
+
</div>
|
|
2584
|
+
`;
|
|
2585
|
+
}).join('');
|
|
2586
|
+
|
|
2587
|
+
tiersEl.innerHTML = html;
|
|
2588
|
+
|
|
2589
|
+
tiersEl.querySelectorAll('.setup-copy').forEach((btn) => {
|
|
2590
|
+
btn.addEventListener('click', () => {
|
|
2591
|
+
const txt = btn.getAttribute('data-copy') || '';
|
|
2592
|
+
navigator.clipboard.writeText(txt).then(() => {
|
|
2593
|
+
const original = btn.textContent;
|
|
2594
|
+
btn.textContent = 'copied!';
|
|
2595
|
+
btn.classList.add('copied');
|
|
2596
|
+
setTimeout(() => {
|
|
2597
|
+
btn.textContent = original;
|
|
2598
|
+
btn.classList.remove('copied');
|
|
2599
|
+
}, 1500);
|
|
2600
|
+
}).catch(() => {});
|
|
2601
|
+
});
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
async function maybeAutoOpenSetupWizard() {
|
|
2606
|
+
// Auto-open only when the server reports firstRun=true and we're not
|
|
2607
|
+
// already in the middle of the onboarding tour. Silent-fail if the
|
|
2608
|
+
// endpoint doesn't exist (server predates Sprint 19 T1).
|
|
2609
|
+
try {
|
|
2610
|
+
const res = await fetch(`${API}/api/setup`);
|
|
2611
|
+
if (!res.ok) return;
|
|
2612
|
+
const data = await res.json();
|
|
2613
|
+
if (data && data.firstRun && !tourState.active) {
|
|
2614
|
+
setTimeout(() => {
|
|
2615
|
+
if (!tourState.active && !setupModalOpen) openSetupModal();
|
|
2616
|
+
}, 800);
|
|
2617
|
+
}
|
|
2618
|
+
} catch {
|
|
2619
|
+
// API not available — skip silently
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2422
2623
|
function fmtUptime(sec) {
|
|
2423
2624
|
const s = Math.floor(sec);
|
|
2424
2625
|
const h = Math.floor(s / 3600);
|
|
@@ -2460,12 +2661,9 @@
|
|
|
2460
2661
|
fetch: () => api('GET', '/api/status'),
|
|
2461
2662
|
render: renderStatusDropdown
|
|
2462
2663
|
});
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
fetch: () => api('GET', '/api/config'),
|
|
2467
|
-
render: renderConfigDropdown
|
|
2468
|
-
});
|
|
2664
|
+
// Sprint 19 T2: config button now opens the setup wizard instead of the
|
|
2665
|
+
// legacy config dropdown (renderConfigDropdown is kept as dead code).
|
|
2666
|
+
document.getElementById('btn-config').addEventListener('click', openSetupModal);
|
|
2469
2667
|
|
|
2470
2668
|
// Onboarding tour wiring
|
|
2471
2669
|
document.getElementById('btn-how').addEventListener('click', startTour);
|