waymark-hub 1.0.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +176 -0
  3. package/dashboard/README.md +71 -0
  4. package/dashboard/index.html +520 -0
  5. package/dashboard/server.mjs +343 -0
  6. package/dashboard/vendor/tailwind.js +83 -0
  7. package/dist/cli/benchmark.d.ts +3 -0
  8. package/dist/cli/benchmark.d.ts.map +1 -0
  9. package/dist/cli/benchmark.js +222 -0
  10. package/dist/cli/benchmark.js.map +1 -0
  11. package/dist/cli/waymark.d.ts +3 -0
  12. package/dist/cli/waymark.d.ts.map +1 -0
  13. package/dist/cli/waymark.js +216 -0
  14. package/dist/cli/waymark.js.map +1 -0
  15. package/dist/context/builder.d.ts +67 -0
  16. package/dist/context/builder.d.ts.map +1 -0
  17. package/dist/context/builder.js +270 -0
  18. package/dist/context/builder.js.map +1 -0
  19. package/dist/db/client.d.ts +5 -0
  20. package/dist/db/client.d.ts.map +1 -0
  21. package/dist/db/client.js +67 -0
  22. package/dist/db/client.js.map +1 -0
  23. package/dist/db/migrations/001_initial.sql +85 -0
  24. package/dist/db/migrations/002_usage_experiments.sql +52 -0
  25. package/dist/db/migrations/003_agent_identity.sql +37 -0
  26. package/dist/db/migrations/004_memory_lifecycle.sql +25 -0
  27. package/dist/db/migrations/005_task_coordination.sql +17 -0
  28. package/dist/db/schema.d.ts +132 -0
  29. package/dist/db/schema.d.ts.map +1 -0
  30. package/dist/db/schema.js +3 -0
  31. package/dist/db/schema.js.map +1 -0
  32. package/dist/server.d.ts +12 -0
  33. package/dist/server.d.ts.map +1 -0
  34. package/dist/server.js +164 -0
  35. package/dist/server.js.map +1 -0
  36. package/dist/telemetry/tokens.d.ts +9 -0
  37. package/dist/telemetry/tokens.d.ts.map +1 -0
  38. package/dist/telemetry/tokens.js +23 -0
  39. package/dist/telemetry/tokens.js.map +1 -0
  40. package/dist/tools/agents.d.ts +3 -0
  41. package/dist/tools/agents.d.ts.map +1 -0
  42. package/dist/tools/agents.js +126 -0
  43. package/dist/tools/agents.js.map +1 -0
  44. package/dist/tools/context.d.ts +3 -0
  45. package/dist/tools/context.d.ts.map +1 -0
  46. package/dist/tools/context.js +53 -0
  47. package/dist/tools/context.js.map +1 -0
  48. package/dist/tools/memory.d.ts +3 -0
  49. package/dist/tools/memory.d.ts.map +1 -0
  50. package/dist/tools/memory.js +273 -0
  51. package/dist/tools/memory.js.map +1 -0
  52. package/dist/tools/projects.d.ts +3 -0
  53. package/dist/tools/projects.d.ts.map +1 -0
  54. package/dist/tools/projects.js +92 -0
  55. package/dist/tools/projects.js.map +1 -0
  56. package/dist/tools/sessions.d.ts +3 -0
  57. package/dist/tools/sessions.d.ts.map +1 -0
  58. package/dist/tools/sessions.js +113 -0
  59. package/dist/tools/sessions.js.map +1 -0
  60. package/dist/tools/tasks.d.ts +3 -0
  61. package/dist/tools/tasks.d.ts.map +1 -0
  62. package/dist/tools/tasks.js +304 -0
  63. package/dist/tools/tasks.js.map +1 -0
  64. package/dist/tools/telemetry.d.ts +3 -0
  65. package/dist/tools/telemetry.d.ts.map +1 -0
  66. package/dist/tools/telemetry.js +291 -0
  67. package/dist/tools/telemetry.js.map +1 -0
  68. package/docs/AGENT_IDENTITY.md +67 -0
  69. package/docs/BENCHMARKING.md +161 -0
  70. package/docs/CONTEXT.md +95 -0
  71. package/docs/MEMORY_LIFECYCLE.md +65 -0
  72. package/docs/TASK_COORDINATION.md +27 -0
  73. package/package.json +70 -0
  74. package/scripts/hooks/session-start-resume.cjs +70 -0
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env node
2
+ // Waymark Hub — read-only observability dashboard.
3
+ //
4
+ // A standalone viewer for the shared hub DB (projects, tasks, memory, sessions,
5
+ // agents). It opens the SQLite file in READ-ONLY mode, so it can never mutate
6
+ // the shared state agents depend on (atomic task claim, FTS triggers). Reuses
7
+ // the hub's already-installed express + better-sqlite3 — no extra deps.
8
+ // Writes, if ever added, must go through the hub's MCP tools, not this server.
9
+
10
+ import express from 'express';
11
+ import Database from 'better-sqlite3';
12
+ import path from 'node:path';
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import crypto from 'node:crypto';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
20
+ const require = createRequire(import.meta.url);
21
+
22
+ // Same DB resolution as the hub itself (DB_PATH → WAYMARK_HOME → install dir → ~/.waymark).
23
+ const { resolveDbPath } = require(path.join(__dirname, '..', 'dist', 'db', 'client.js'));
24
+ const DB_PATH = resolveDbPath();
25
+ const PORT = parseInt(process.env.DASH_PORT || '4747', 10);
26
+ const HOST = '127.0.0.1'; // loopback only
27
+
28
+ const db = new Database(DB_PATH, { readonly: true, fileMustExist: true });
29
+
30
+ /** Parse a column that stores a JSON array/object as text; tolerate plain text. */
31
+ function parseJson(value, fallback) {
32
+ if (value == null || value === '') return fallback;
33
+ try { return JSON.parse(value); } catch { return fallback; }
34
+ }
35
+
36
+ // ---- Optional admin mode ----------------------------------------------------
37
+ // WAYMARK_ADMIN=1 enables a small set of write actions. Writes go through the
38
+ // hub's own MCP tools (in-process client), never raw SQL — so task_update /
39
+ // memory_set_status semantics (completed_at, FTS, supersede) stay intact.
40
+ const ADMIN = process.env.WAYMARK_ADMIN === '1';
41
+ let adminClient = null;
42
+ async function initAdmin() {
43
+ process.env.HUB_TOOLS = 'full';
44
+ const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
45
+ const { InMemoryTransport } = require('@modelcontextprotocol/sdk/inMemory.js');
46
+ const { createMcpServer } = require(path.join(__dirname, '..', 'dist', 'server.js'));
47
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
48
+ const client = new Client({ name: 'waymark-dashboard-admin', version: '1.0.0' });
49
+ const mcpServer = createMcpServer();
50
+ await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]);
51
+ return client;
52
+ }
53
+
54
+ async function adminTool(res, name, args) {
55
+ if (!adminClient) return res.status(403).json({ error: 'admin mode is off (set WAYMARK_ADMIN=1)' });
56
+ try {
57
+ const result = await adminClient.callTool({ name, arguments: args });
58
+ const text = result.content?.find(c => c.type === 'text')?.text ?? '';
59
+ if (result.isError) return res.status(400).json({ error: text });
60
+ res.json({ ok: true, result: text });
61
+ } catch (e) {
62
+ res.status(500).json({ error: String(e.message || e) });
63
+ }
64
+ }
65
+
66
+ const app = express();
67
+
68
+ app.get('/api/capabilities', (_req, res) => {
69
+ res.json({ admin: ADMIN, db_path: DB_PATH });
70
+ });
71
+
72
+ // Cheap change detection for live refresh: data_version increments whenever
73
+ // another connection commits to the DB.
74
+ app.get('/api/stats', (_req, res) => {
75
+ res.json({
76
+ data_version: db.pragma('data_version', { simple: true }),
77
+ now: new Date().toISOString(),
78
+ });
79
+ });
80
+
81
+ // Trail: the handoff timeline for a project — sessions in reverse chronology
82
+ // plus the active auto-handoff (what the next agent should pick up).
83
+ app.get('/api/trail', (req, res) => {
84
+ const projectId = req.query.project_id || null;
85
+ const where = projectId ? 'WHERE s.project_id = ?' : '';
86
+ const params = projectId ? [projectId] : [];
87
+ const sessions = db.prepare(`
88
+ SELECT s.id, s.project_id, s.started_at, s.ended_at, s.summary, s.outcome,
89
+ s.agent_id, s.provider, s.model, s.client, s.surface,
90
+ s.files_touched, s.commits_made,
91
+ p.name AS project_name, a.display_name AS agent_name
92
+ FROM sessions s
93
+ LEFT JOIN projects p ON p.id = s.project_id
94
+ LEFT JOIN agents a ON a.id = s.agent_id
95
+ ${where}
96
+ ORDER BY COALESCE(s.ended_at, s.started_at) DESC
97
+ LIMIT 100`).all(...params).map(r => ({
98
+ ...r,
99
+ files_touched: parseJson(r.files_touched, []),
100
+ commits_made: parseJson(r.commits_made, []),
101
+ }));
102
+ const handoffs = db.prepare(`
103
+ SELECT m.id, m.project_id, m.name, m.description, m.body, m.status,
104
+ m.updated_at, m.origin_session, m.created_by_agent,
105
+ a.display_name AS agent_name, p.name AS project_name
106
+ FROM memory_nodes m
107
+ LEFT JOIN agents a ON a.id = m.created_by_agent
108
+ LEFT JOIN projects p ON p.id = m.project_id
109
+ WHERE m.type = 'handoff' ${projectId ? 'AND m.project_id = ?' : ''}
110
+ ORDER BY m.updated_at DESC`).all(...params);
111
+ res.json({ sessions, handoffs });
112
+ });
113
+
114
+ app.get('/api/overview', (_req, res) => {
115
+ const count = (t) => db.prepare(`SELECT COUNT(*) c FROM ${t}`).get().c;
116
+ res.json({
117
+ db_path: DB_PATH,
118
+ counts: {
119
+ projects: count('projects'),
120
+ tasks: count('tasks'),
121
+ memory: count('memory_nodes'),
122
+ sessions: count('sessions'),
123
+ agents: count('agents'),
124
+ experiments: count('experiments'),
125
+ },
126
+ tasks_by_status: db.prepare(
127
+ `SELECT status, COUNT(*) c FROM tasks GROUP BY status ORDER BY c DESC`,
128
+ ).all(),
129
+ });
130
+ });
131
+
132
+ app.get('/api/projects', (_req, res) => {
133
+ res.json(db.prepare(
134
+ `SELECT id, name, root_path, stack, status, description, created_at, updated_at
135
+ FROM projects ORDER BY updated_at DESC, name`,
136
+ ).all());
137
+ });
138
+
139
+ app.get('/api/tasks', (req, res) => {
140
+ const where = [];
141
+ const params = [];
142
+ if (req.query.project_id) { where.push('t.project_id = ?'); params.push(req.query.project_id); }
143
+ if (req.query.status) { where.push('t.status = ?'); params.push(req.query.status); }
144
+ const sql = `
145
+ SELECT t.id, t.project_id, t.title, t.description, t.status, t.priority,
146
+ t.created_by, t.assigned_to, t.assigned_agent_id, t.claimed_by_agent,
147
+ t.claimed_at, t.blocker, t.progress, t.required_capabilities,
148
+ t.created_at, t.updated_at, t.completed_at,
149
+ p.name AS project_name, a.display_name AS claimed_agent_name
150
+ FROM tasks t
151
+ LEFT JOIN projects p ON p.id = t.project_id
152
+ LEFT JOIN agents a ON a.id = t.claimed_by_agent
153
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
154
+ ORDER BY t.priority DESC, t.created_at DESC`;
155
+ const rows = db.prepare(sql).all(...params).map(r => ({
156
+ ...r,
157
+ required_capabilities: parseJson(r.required_capabilities, []),
158
+ }));
159
+ res.json(rows);
160
+ });
161
+
162
+ app.get('/api/memory', (req, res) => {
163
+ const where = [];
164
+ const params = [];
165
+ if (req.query.project_id) { where.push('project_id = ?'); params.push(req.query.project_id); }
166
+ if (req.query.type) { where.push('type = ?'); params.push(req.query.type); }
167
+ if (req.query.status) { where.push('status = ?'); params.push(req.query.status); }
168
+ if (req.query.q) {
169
+ where.push('(name LIKE ? OR description LIKE ? OR body LIKE ? OR tags LIKE ?)');
170
+ const like = `%${req.query.q}%`;
171
+ params.push(like, like, like, like);
172
+ }
173
+ const sql = `
174
+ SELECT id, project_id, name, description, type, body, tags, status,
175
+ importance, confidence, surface, created_by_agent, created_at, updated_at
176
+ FROM memory_nodes
177
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
178
+ ORDER BY importance DESC, updated_at DESC`;
179
+ const rows = db.prepare(sql).all(...params).map(r => ({
180
+ ...r,
181
+ tags: parseJson(r.tags, []),
182
+ }));
183
+ res.json(rows);
184
+ });
185
+
186
+ app.get('/api/sessions', (req, res) => {
187
+ const where = [];
188
+ const params = [];
189
+ if (req.query.project_id) { where.push('s.project_id = ?'); params.push(req.query.project_id); }
190
+ const sql = `
191
+ SELECT s.id, s.project_id, s.surface, s.started_at, s.ended_at, s.summary,
192
+ s.files_touched, s.commits_made, s.outcome, s.agent_id,
193
+ s.provider, s.model, s.client,
194
+ p.name AS project_name, a.display_name AS agent_name
195
+ FROM sessions s
196
+ LEFT JOIN projects p ON p.id = s.project_id
197
+ LEFT JOIN agents a ON a.id = s.agent_id
198
+ ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
199
+ ORDER BY s.started_at DESC`;
200
+ const rows = db.prepare(sql).all(...params).map(r => ({
201
+ ...r,
202
+ files_touched: parseJson(r.files_touched, []),
203
+ commits_made: parseJson(r.commits_made, []),
204
+ }));
205
+ res.json(rows);
206
+ });
207
+
208
+ app.get('/api/agents', (_req, res) => {
209
+ const rows = db.prepare(
210
+ `SELECT id, display_name, provider, model, client, client_version,
211
+ capabilities, status, created_at, updated_at
212
+ FROM agents ORDER BY updated_at DESC`,
213
+ ).all().map(r => ({ ...r, capabilities: parseJson(r.capabilities, []) }));
214
+ res.json(rows);
215
+ });
216
+
217
+ app.get('/api/experiments', (_req, res) => {
218
+ res.json(db.prepare(
219
+ `SELECT e.id, e.project_id, e.name, e.description, e.scenario, e.status,
220
+ e.target_runs, e.created_at, e.updated_at, p.name AS project_name
221
+ FROM experiments e
222
+ LEFT JOIN projects p ON p.id = e.project_id
223
+ ORDER BY e.created_at DESC`,
224
+ ).all());
225
+ });
226
+
227
+ // ---- Optional Russian translation (opt-in, cached) -------------------------
228
+ // Translates English hub content on demand via the user's existing DeepSeek
229
+ // key, reusing the same resolution dsc uses (env → ~/.deepseek-code/settings.json).
230
+ // The key stays server-side; every unique string is translated once and cached
231
+ // to disk, so repeat views cost nothing. Off unless the UI requests it.
232
+
233
+ const CACHE_DIR = path.join(__dirname, '.cache');
234
+ const CACHE_FILE = path.join(CACHE_DIR, 'translations.json');
235
+ let trCache = {};
236
+ try { trCache = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { trCache = {}; }
237
+ let cacheDirty = false;
238
+ function persistCache() {
239
+ if (!cacheDirty) return;
240
+ try { fs.mkdirSync(CACHE_DIR, { recursive: true }); fs.writeFileSync(CACHE_FILE, JSON.stringify(trCache)); cacheDirty = false; } catch { /* non-fatal */ }
241
+ }
242
+ setInterval(persistCache, 5000).unref();
243
+
244
+ function translateConfig() {
245
+ let settings = {};
246
+ try { settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.deepseek-code', 'settings.json'), 'utf8')); } catch { /* none */ }
247
+ const apiKey = (process.env.DEEPSEEK_API_KEY || settings.apiKey || '').trim();
248
+ const baseUrl = (process.env.DEEPSEEK_BASE_URL || settings.baseUrl || 'https://api.deepseek.com').replace(/\/$/, '');
249
+ const model = process.env.TRANSLATE_MODEL || 'deepseek-v4-flash';
250
+ return { apiKey, baseUrl, model };
251
+ }
252
+
253
+ const hash = (s) => crypto.createHash('sha1').update(s).digest('hex');
254
+
255
+ async function translateBatch(texts, cfg) {
256
+ const prompt = 'Translate each string in the following JSON array to Russian. '
257
+ + 'Return ONLY a JSON array of strings, same length and order. Translate prose only; '
258
+ + 'keep code, identifiers, slugs, file paths, URLs, and IDs unchanged.\n\n'
259
+ + JSON.stringify(texts);
260
+ const resp = await fetch(`${cfg.baseUrl}/chat/completions`, {
261
+ method: 'POST',
262
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.apiKey}` },
263
+ body: JSON.stringify({
264
+ model: cfg.model,
265
+ temperature: 0,
266
+ messages: [
267
+ { role: 'system', content: 'You are a precise translator. Output strictly valid JSON, nothing else.' },
268
+ { role: 'user', content: prompt },
269
+ ],
270
+ }),
271
+ });
272
+ if (!resp.ok) throw new Error(`translate API ${resp.status}`);
273
+ const data = await resp.json();
274
+ let content = data.choices?.[0]?.message?.content ?? '';
275
+ content = content.replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
276
+ const arr = JSON.parse(content);
277
+ if (!Array.isArray(arr) || arr.length !== texts.length) throw new Error('translation shape mismatch');
278
+ return arr.map(String);
279
+ }
280
+
281
+ app.use(express.json({ limit: '4mb' }));
282
+
283
+ app.post('/api/translate', async (req, res) => {
284
+ const texts = Array.isArray(req.body?.texts) ? req.body.texts.filter(t => typeof t === 'string') : [];
285
+ const cfg = translateConfig();
286
+ const out = {};
287
+ const missing = [];
288
+ for (const t of texts) {
289
+ const k = hash(t);
290
+ if (trCache[k] != null) out[t] = trCache[k];
291
+ else if (!missing.includes(t)) missing.push(t);
292
+ }
293
+ if (missing.length === 0) { process.stdout.write(`[translate] ${texts.length} texts, all cached\n`); return res.json({ translations: out }); }
294
+ if (!cfg.apiKey) { process.stdout.write('[translate] NO API KEY (set DEEPSEEK_API_KEY or apiKey in ~/.deepseek-code/settings.json)\n'); return res.json({ translations: out, error: 'no_key' }); }
295
+ try {
296
+ process.stdout.write(`[translate] calling ${cfg.baseUrl} model=${cfg.model} for ${missing.length} new texts…\n`);
297
+ const translated = await translateBatch(missing, cfg);
298
+ missing.forEach((src, i) => { trCache[hash(src)] = translated[i]; out[src] = translated[i]; });
299
+ cacheDirty = true; persistCache();
300
+ process.stdout.write(`[translate] ok (${missing.length} translated, ${Object.keys(trCache).length} cached total)\n`);
301
+ res.json({ translations: out });
302
+ } catch (e) {
303
+ process.stdout.write(`[translate] ERROR: ${String(e.message || e)}\n`);
304
+ res.json({ translations: out, error: String(e.message || e) });
305
+ }
306
+ });
307
+
308
+ // Admin write endpoints (403 unless WAYMARK_ADMIN=1). Statuses are validated
309
+ // by the hub tools' own schemas.
310
+ app.post('/api/admin/task-status', (req, res) => {
311
+ const { id, status } = req.body ?? {};
312
+ if (!id || !status) return res.status(400).json({ error: 'id and status are required' });
313
+ adminTool(res, 'task_update', { id, status });
314
+ });
315
+
316
+ app.post('/api/admin/memory-status', (req, res) => {
317
+ const { id, status } = req.body ?? {};
318
+ if (!id || !status) return res.status(400).json({ error: 'id and status are required' });
319
+ adminTool(res, 'memory_set_status', { id, status });
320
+ });
321
+
322
+ app.use(express.static(__dirname));
323
+
324
+ if (ADMIN) {
325
+ adminClient = await initAdmin();
326
+ }
327
+
328
+ const server = app.listen(PORT, HOST, () => {
329
+ process.stdout.write(`Waymark dashboard (${ADMIN ? 'ADMIN mode' : 'read-only'}) → http://${HOST}:${PORT}\n`);
330
+ process.stdout.write(`Reading: ${DB_PATH}\n`);
331
+ });
332
+
333
+ server.on('error', (err) => {
334
+ if (err.code === 'EADDRINUSE') {
335
+ process.stderr.write(
336
+ `\n[!] Порт ${PORT} уже занят другим процессом (вероятно, старый дашборд ещё работает).\n` +
337
+ ` Останови его или запусти на другом порту: $env:DASH_PORT='4748'; npm run dashboard\n`,
338
+ );
339
+ } else {
340
+ process.stderr.write(`\n[!] Не удалось запустить дашборд: ${err.message}\n`);
341
+ }
342
+ process.exit(1);
343
+ });