stenotype 0.2.0 → 0.3.0

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.
@@ -0,0 +1,479 @@
1
+ import express from 'express';
2
+ import session from 'express-session';
3
+ import bcrypt from 'bcryptjs';
4
+ import keytar from 'keytar';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ const SERVICE_NAME = 'stenotype-dashboard';
14
+ const PORT = parseInt(process.env.DASHBOARD_PORT || '3737', 10);
15
+ const HOST = '0.0.0.0';
16
+
17
+ // Load credentials from OS keychain
18
+ let USERNAME, PASSWORD_HASH, SESSION_SECRET;
19
+ try {
20
+ [USERNAME, PASSWORD_HASH, SESSION_SECRET] = await Promise.all([
21
+ keytar.getPassword(SERVICE_NAME, 'username'),
22
+ keytar.getPassword(SERVICE_NAME, 'password_hash'),
23
+ keytar.getPassword(SERVICE_NAME, 'session_secret'),
24
+ ]);
25
+ } catch (err) {
26
+ console.error('Failed to load dashboard credentials from keychain:', err.message);
27
+ process.exit(1);
28
+ }
29
+
30
+ if (!USERNAME || !PASSWORD_HASH || !SESSION_SECRET) {
31
+ console.error('\n Dashboard credentials not configured.');
32
+ console.error(' Run: stenotype dashboard setup\n');
33
+ process.exit(1);
34
+ }
35
+
36
+ // Stenotype config paths
37
+ const STENO_DIR = path.join(os.homedir(), '.stenotype');
38
+ const CONFIG_PATH = path.join(STENO_DIR, 'stenotype.config.json');
39
+ const LICENSE_PATH = path.join(STENO_DIR, 'license.key');
40
+
41
+ // DB path
42
+ const DB_PATH = path.join(os.homedir(), '.stenotype', 'memories.db');
43
+
44
+ let db;
45
+ try {
46
+ db = new DatabaseSync(DB_PATH);
47
+ console.log(`Connected to DB: ${DB_PATH}`);
48
+ } catch (err) {
49
+ console.error(`Failed to open DB at ${DB_PATH}:`, err.message);
50
+ console.error('Server will start but API routes will fail until DB is available.');
51
+ }
52
+
53
+ const app = express();
54
+
55
+ app.use(express.json());
56
+ app.use(express.urlencoded({ extended: true }));
57
+
58
+ app.use(
59
+ session({
60
+ secret: SESSION_SECRET,
61
+ resave: false,
62
+ saveUninitialized: false,
63
+ cookie: {
64
+ secure: false,
65
+ httpOnly: true,
66
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
67
+ },
68
+ })
69
+ );
70
+
71
+ // Serve static files
72
+ app.use(express.static(path.join(__dirname, 'public')));
73
+
74
+ // Root redirect
75
+ app.get('/', (req, res) => {
76
+ res.redirect('/login.html');
77
+ });
78
+
79
+ // Auth routes (no auth required)
80
+ app.post('/auth/login', (req, res) => {
81
+ const { username, password } = req.body;
82
+ if (!username || !password) {
83
+ return res.status(400).json({ error: 'Username and password required.' });
84
+ }
85
+ if (username !== USERNAME) {
86
+ return res.status(401).json({ error: 'Invalid credentials.' });
87
+ }
88
+ const valid = bcrypt.compareSync(password, PASSWORD_HASH);
89
+ if (!valid) {
90
+ return res.status(401).json({ error: 'Invalid credentials.' });
91
+ }
92
+ req.session.user = { username };
93
+ res.json({ ok: true, user: { username } });
94
+ });
95
+
96
+ app.post('/auth/logout', (req, res) => {
97
+ req.session.destroy(() => {
98
+ res.json({ ok: true });
99
+ });
100
+ });
101
+
102
+ app.get('/auth/me', (req, res) => {
103
+ if (req.session && req.session.user) {
104
+ return res.json({ user: req.session.user });
105
+ }
106
+ res.json({ user: null });
107
+ });
108
+
109
+ // Auth-only middleware (no db check) for setup/settings routes
110
+ function requireAuth(req, res, next) {
111
+ if (!req.session || !req.session.user) {
112
+ return res.status(401).json({ error: 'Unauthorized.' });
113
+ }
114
+ next();
115
+ }
116
+
117
+ // GET /api/setup-status
118
+ app.get('/api/setup-status', requireAuth, (req, res) => {
119
+ try {
120
+ if (!existsSync(CONFIG_PATH)) return res.json({ needsSetup: true });
121
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
122
+ res.json({ needsSetup: !cfg.setup_complete });
123
+ } catch {
124
+ res.json({ needsSetup: true });
125
+ }
126
+ });
127
+
128
+ // POST /api/setup
129
+ app.post('/api/setup', requireAuth, (req, res) => {
130
+ try {
131
+ const { licenseKey, geminiApiKey, embeddingChoice, platform } = req.body;
132
+ if (!licenseKey || !geminiApiKey) {
133
+ return res.status(400).json({ error: 'License key and Gemini API key are required.' });
134
+ }
135
+ mkdirSync(STENO_DIR, { recursive: true });
136
+ writeFileSync(LICENSE_PATH, licenseKey, { mode: 0o600 });
137
+
138
+ let existing = {};
139
+ if (existsSync(CONFIG_PATH)) {
140
+ try { existing = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch {}
141
+ }
142
+
143
+ const config = {
144
+ ...existing,
145
+ database: DB_PATH,
146
+ extractionProvider: 'openai',
147
+ extractionModel: 'gemini-2.5-flash-lite',
148
+ extractionApiKey: geminiApiKey,
149
+ extractionApiBase: 'https://generativelanguage.googleapis.com/v1beta/openai/',
150
+ embeddingProvider: embeddingChoice || 'none',
151
+ embeddingModel: embeddingChoice === 'local' ? 'Qwen3-Embedding-0.6B-Q8_0' : '',
152
+ embeddingApiKey: '',
153
+ embeddingApiBase: '',
154
+ systemIntegration: platform || 'ductor',
155
+ logWatchPaths: [path.join(os.homedir(), '.claude', 'projects')],
156
+ ductorSessionsWatch: true,
157
+ setup_complete: true,
158
+ };
159
+
160
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
161
+ res.json({ ok: true });
162
+ } catch (err) {
163
+ res.status(500).json({ error: err.message });
164
+ }
165
+ });
166
+
167
+ // GET /api/settings
168
+ app.get('/api/settings', requireAuth, (req, res) => {
169
+ try {
170
+ if (!existsSync(CONFIG_PATH)) return res.json({ settings: null });
171
+ const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
172
+ const masked = cfg.extractionApiKey
173
+ ? '••••••••' + cfg.extractionApiKey.slice(-4)
174
+ : '';
175
+ res.json({
176
+ settings: {
177
+ geminiApiKeyMasked: masked,
178
+ embeddingProvider: cfg.embeddingProvider || 'none',
179
+ systemIntegration: cfg.systemIntegration || 'ductor',
180
+ extractionModel: cfg.extractionModel || 'gemini-2.5-flash-lite',
181
+ },
182
+ });
183
+ } catch (err) {
184
+ res.status(500).json({ error: err.message });
185
+ }
186
+ });
187
+
188
+ // PUT /api/settings
189
+ app.put('/api/settings', requireAuth, (req, res) => {
190
+ try {
191
+ let existing = {};
192
+ if (existsSync(CONFIG_PATH)) {
193
+ try { existing = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch {}
194
+ }
195
+
196
+ const { geminiApiKey, embeddingProvider, systemIntegration } = req.body;
197
+ if (geminiApiKey && geminiApiKey.length >= 10) {
198
+ existing.extractionApiKey = geminiApiKey;
199
+ }
200
+ if (embeddingProvider) {
201
+ existing.embeddingProvider = embeddingProvider;
202
+ existing.embeddingModel = embeddingProvider === 'local' ? 'Qwen3-Embedding-0.6B-Q8_0' : '';
203
+ }
204
+ if (systemIntegration) {
205
+ existing.systemIntegration = systemIntegration;
206
+ }
207
+
208
+ writeFileSync(CONFIG_PATH, JSON.stringify(existing, null, 2));
209
+ res.json({ ok: true });
210
+ } catch (err) {
211
+ res.status(500).json({ error: err.message });
212
+ }
213
+ });
214
+
215
+ // Auth middleware for /api routes
216
+ app.use('/api', (req, res, next) => {
217
+ if (!req.session || !req.session.user) {
218
+ return res.status(401).json({ error: 'Unauthorized.' });
219
+ }
220
+ if (!db) {
221
+ return res.status(503).json({ error: 'Database not available.' });
222
+ }
223
+ next();
224
+ });
225
+
226
+ // GET /api/stats
227
+ app.get('/api/stats', (req, res) => {
228
+ try {
229
+ const totalRow = db.prepare('SELECT COUNT(*) as count FROM memories').get();
230
+ const activeRow = db.prepare('SELECT COUNT(*) as count FROM memories WHERE is_active = 1').get();
231
+ const recentRow = db.prepare(
232
+ "SELECT COUNT(*) as count FROM memories WHERE is_active = 1 AND created_at >= datetime('now', '-7 days')"
233
+ ).get();
234
+
235
+ const byCategory = db
236
+ .prepare('SELECT category, COUNT(*) as count FROM memories WHERE is_active = 1 GROUP BY category ORDER BY count DESC')
237
+ .all();
238
+
239
+ const byTemperature = db
240
+ .prepare('SELECT temperature, COUNT(*) as count FROM memories WHERE is_active = 1 GROUP BY temperature ORDER BY count DESC')
241
+ .all();
242
+
243
+ const byAgent = db
244
+ .prepare('SELECT agent_id, COUNT(*) as count FROM memories WHERE is_active = 1 GROUP BY agent_id ORDER BY count DESC')
245
+ .all();
246
+
247
+ const archivedRow = db.prepare('SELECT COUNT(*) as count FROM memories WHERE is_active = 0').get();
248
+
249
+ res.json({
250
+ total: totalRow.count,
251
+ active: activeRow.count,
252
+ archived: archivedRow.count,
253
+ recentCount: recentRow.count,
254
+ byCategory,
255
+ byTemperature,
256
+ byAgent,
257
+ });
258
+ } catch (err) {
259
+ console.error('Stats error:', err);
260
+ res.status(500).json({ error: err.message });
261
+ }
262
+ });
263
+
264
+ // GET /api/memories
265
+ app.get('/api/memories', (req, res) => {
266
+ try {
267
+ const { category, temperature, agent_id, page = 1, limit = 20, q, archived, date_from } = req.query;
268
+ const isArchived = archived === 'true';
269
+ const activeFilter = isArchived ? 0 : 1;
270
+ const offset = (parseInt(page) - 1) * parseInt(limit);
271
+ const lim = parseInt(limit);
272
+
273
+ const SELECT_COLS = `
274
+ m.id, m.category, m.content, m.subject, m.confidence, m.importance,
275
+ m.tags, m.source_channel, m.agent_id, m.temperature, m.created_at,
276
+ m.updated_at, m.is_active, m.access_count, m.last_accessed_at,
277
+ m.supersedes_id, m.superseded_by, m.extracted_by
278
+ `;
279
+
280
+ if (q && q.trim()) {
281
+ // FTS search
282
+ const searchTerm = q.trim();
283
+ let ftsQuery = `
284
+ SELECT ${SELECT_COLS}
285
+ FROM memories m
286
+ JOIN memories_fts fts ON m.id = fts.rowid
287
+ WHERE memories_fts MATCH ? AND m.is_active = ?
288
+ `;
289
+ const params = [searchTerm, activeFilter];
290
+
291
+ if (category && category !== 'all') {
292
+ ftsQuery += ` AND m.category = ?`;
293
+ params.push(category);
294
+ }
295
+ if (temperature && temperature !== 'all') {
296
+ ftsQuery += ` AND m.temperature = ?`;
297
+ params.push(temperature);
298
+ }
299
+ if (agent_id && agent_id !== 'all') {
300
+ ftsQuery += ` AND m.agent_id = ?`;
301
+ params.push(agent_id);
302
+ }
303
+ if (date_from) {
304
+ ftsQuery += ` AND m.created_at >= ?`;
305
+ params.push(date_from);
306
+ }
307
+
308
+ ftsQuery += ` ORDER BY rank LIMIT ? OFFSET ?`;
309
+ params.push(lim, offset);
310
+
311
+ let memories;
312
+ try {
313
+ memories = db.prepare(ftsQuery).all(...params);
314
+ } catch (ftsErr) {
315
+ // FTS table might not exist or rowid join might fail - fall back to LIKE search
316
+ console.warn('FTS search failed, falling back to LIKE:', ftsErr.message);
317
+ const likePattern = `%${searchTerm}%`;
318
+ let fallbackQuery = `
319
+ SELECT ${SELECT_COLS}
320
+ FROM memories m
321
+ WHERE m.is_active = ? AND (m.content LIKE ? OR m.subject LIKE ? OR m.tags LIKE ?)
322
+ `;
323
+ const fbParams = [activeFilter, likePattern, likePattern, likePattern];
324
+ if (category && category !== 'all') { fallbackQuery += ` AND m.category = ?`; fbParams.push(category); }
325
+ if (temperature && temperature !== 'all') { fallbackQuery += ` AND m.temperature = ?`; fbParams.push(temperature); }
326
+ if (agent_id && agent_id !== 'all') { fallbackQuery += ` AND m.agent_id = ?`; fbParams.push(agent_id); }
327
+ if (date_from) { fallbackQuery += ` AND m.created_at >= ?`; fbParams.push(date_from); }
328
+ fallbackQuery += ` ORDER BY m.created_at DESC LIMIT ? OFFSET ?`;
329
+ fbParams.push(lim, offset);
330
+ memories = db.prepare(fallbackQuery).all(...fbParams);
331
+ }
332
+
333
+ // Count for pagination
334
+ let countQuery = `
335
+ SELECT COUNT(*) as count
336
+ FROM memories m
337
+ WHERE m.is_active = ? AND (m.content LIKE ? OR m.subject LIKE ? OR m.tags LIKE ?)
338
+ `;
339
+ const likePattern = `%${searchTerm}%`;
340
+ const countParams = [activeFilter, likePattern, likePattern, likePattern];
341
+ if (category && category !== 'all') { countQuery += ` AND m.category = ?`; countParams.push(category); }
342
+ if (temperature && temperature !== 'all') { countQuery += ` AND m.temperature = ?`; countParams.push(temperature); }
343
+ if (agent_id && agent_id !== 'all') { countQuery += ` AND m.agent_id = ?`; countParams.push(agent_id); }
344
+ if (date_from) { countQuery += ` AND m.created_at >= ?`; countParams.push(date_from); }
345
+ const countRow = db.prepare(countQuery).get(...countParams);
346
+
347
+ return res.json({ memories, total: countRow.count, page: parseInt(page) });
348
+ }
349
+
350
+ // Regular filtered query
351
+ let query = `SELECT ${SELECT_COLS} FROM memories m WHERE m.is_active = ?`;
352
+ const params = [activeFilter];
353
+
354
+ if (category && category !== 'all') {
355
+ query += ` AND m.category = ?`;
356
+ params.push(category);
357
+ }
358
+ if (temperature && temperature !== 'all') {
359
+ query += ` AND m.temperature = ?`;
360
+ params.push(temperature);
361
+ }
362
+ if (agent_id && agent_id !== 'all') {
363
+ query += ` AND m.agent_id = ?`;
364
+ params.push(agent_id);
365
+ }
366
+ if (date_from) {
367
+ query += ` AND m.created_at >= ?`;
368
+ params.push(date_from);
369
+ }
370
+
371
+ query += ` ORDER BY m.created_at DESC LIMIT ? OFFSET ?`;
372
+ params.push(lim, offset);
373
+
374
+ const memories = db.prepare(query).all(...params);
375
+
376
+ // Count
377
+ let countQuery = `SELECT COUNT(*) as count FROM memories m WHERE m.is_active = ?`;
378
+ const countParams = [activeFilter];
379
+ if (category && category !== 'all') { countQuery += ` AND m.category = ?`; countParams.push(category); }
380
+ if (temperature && temperature !== 'all') { countQuery += ` AND m.temperature = ?`; countParams.push(temperature); }
381
+ if (agent_id && agent_id !== 'all') { countQuery += ` AND m.agent_id = ?`; countParams.push(agent_id); }
382
+ if (date_from) { countQuery += ` AND m.created_at >= ?`; countParams.push(date_from); }
383
+
384
+ const countRow = db.prepare(countQuery).get(...countParams);
385
+
386
+ res.json({ memories, total: countRow.count, page: parseInt(page) });
387
+ } catch (err) {
388
+ console.error('Memories error:', err);
389
+ res.status(500).json({ error: err.message });
390
+ }
391
+ });
392
+
393
+ // GET /api/memories/:id
394
+ app.get('/api/memories/:id', (req, res) => {
395
+ try {
396
+ const memory = db.prepare(`
397
+ SELECT id, category, content, subject, confidence, importance,
398
+ tags, source_channel, agent_id, temperature, created_at,
399
+ updated_at, is_active, access_count, last_accessed_at,
400
+ supersedes_id, superseded_by, extracted_by
401
+ FROM memories WHERE id = ?
402
+ `).get(req.params.id);
403
+
404
+ if (!memory) {
405
+ return res.status(404).json({ error: 'Memory not found.' });
406
+ }
407
+ res.json(memory);
408
+ } catch (err) {
409
+ console.error('Get memory error:', err);
410
+ res.status(500).json({ error: err.message });
411
+ }
412
+ });
413
+
414
+ // DELETE /api/memories/:id (soft delete)
415
+ app.delete('/api/memories/:id', (req, res) => {
416
+ try {
417
+ const result = db.prepare(
418
+ `UPDATE memories SET is_active = 0, updated_at = datetime('now') WHERE id = ?`
419
+ ).run(req.params.id);
420
+
421
+ if (result.changes === 0) {
422
+ return res.status(404).json({ error: 'Memory not found.' });
423
+ }
424
+ res.json({ ok: true });
425
+ } catch (err) {
426
+ console.error('Delete memory error:', err);
427
+ res.status(500).json({ error: err.message });
428
+ }
429
+ });
430
+
431
+ // PUT /api/memories/:id/restore
432
+ app.put('/api/memories/:id/restore', (req, res) => {
433
+ try {
434
+ const result = db.prepare(
435
+ `UPDATE memories SET is_active = 1, temperature = 'hot',
436
+ last_accessed_at = datetime('now'), access_count = access_count + 1,
437
+ updated_at = datetime('now') WHERE id = ?`
438
+ ).run(req.params.id);
439
+ if (result.changes === 0) return res.status(404).json({ error: 'Memory not found.' });
440
+ res.json({ ok: true });
441
+ } catch (err) {
442
+ console.error('Restore error:', err);
443
+ res.status(500).json({ error: err.message });
444
+ }
445
+ });
446
+
447
+ // GET /api/pinned
448
+ app.get('/api/pinned', (req, res) => {
449
+ try {
450
+ const pinned = db.prepare(`
451
+ SELECT id, memory_id, content, category, agent_id, created_at, updated_at
452
+ FROM pinned_memories
453
+ ORDER BY created_at DESC
454
+ `).all();
455
+ res.json({ pinned });
456
+ } catch (err) {
457
+ console.error('Pinned error:', err);
458
+ res.status(500).json({ error: err.message });
459
+ }
460
+ });
461
+
462
+ // GET /api/commitments
463
+ app.get('/api/commitments', (req, res) => {
464
+ try {
465
+ const commitments = db.prepare(`
466
+ SELECT id, commitment, source, state, agent_id, created_at, updated_at
467
+ FROM commitment_ledger
468
+ ORDER BY created_at DESC
469
+ `).all();
470
+ res.json({ commitments });
471
+ } catch (err) {
472
+ console.error('Commitments error:', err);
473
+ res.status(500).json({ error: err.message });
474
+ }
475
+ });
476
+
477
+ app.listen(PORT, HOST, () => {
478
+ console.log(`Stenotype Dashboard running at http://localhost:${PORT}`);
479
+ });
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stenotype",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Automatic memory capture and recall for AI agent workflows",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -9,7 +9,8 @@
9
9
  },
10
10
  "files": [
11
11
  "dist/index.cjs",
12
- "dist/stenotype.jsc"
12
+ "dist/stenotype.jsc",
13
+ "dist/dashboard"
13
14
  ],
14
15
  "scripts": {
15
16
  "build": "tsc",
@@ -19,7 +20,11 @@
19
20
  },
20
21
  "dependencies": {
21
22
  "@anthropic-ai/sdk": "^0.90.0",
23
+ "bcryptjs": "^2.4.3",
22
24
  "bytenode": "^1.5.7",
25
+ "express": "^4.18.2",
26
+ "express-session": "^1.17.3",
27
+ "keytar": "^7.9.0",
23
28
  "openai": "^4.78.1",
24
29
  "sqlite-vec": "^0.1.7",
25
30
  "sqlite-vec-linux-x64": "^0.1.7"