@jhizzard/termdeck 0.2.5 → 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.
@@ -10,9 +10,37 @@ const fs = require('fs');
10
10
  const { v4: uuidv4 } = require('uuid');
11
11
 
12
12
  // Conditional imports (graceful fallback if not installed yet)
13
- let pty, Database;
13
+ let pty, Database, pg;
14
14
  try { pty = require('@homebridge/node-pty-prebuilt-multiarch'); } catch { pty = null; }
15
15
  try { Database = require('better-sqlite3'); } catch { Database = null; }
16
+ try { pg = require('pg'); } catch { pg = null; }
17
+
18
+ // Module-level singleton Postgres pool for rumen_insights (petvetbid DB).
19
+ // Lazy-initialized on first rumen endpoint hit so startup stays fast and
20
+ // servers without DATABASE_URL never pay the connection cost.
21
+ let _rumenPool = null;
22
+ let _rumenPoolFailed = false;
23
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
+ function getRumenPool() {
25
+ if (_rumenPool || _rumenPoolFailed) return _rumenPool;
26
+ if (!pg || !process.env.DATABASE_URL) return null;
27
+ try {
28
+ _rumenPool = new pg.Pool({
29
+ connectionString: process.env.DATABASE_URL,
30
+ max: 4,
31
+ idleTimeoutMillis: 30000,
32
+ connectionTimeoutMillis: 5000
33
+ });
34
+ _rumenPool.on('error', (err) => {
35
+ console.warn('[rumen] pg pool error:', err.message);
36
+ });
37
+ return _rumenPool;
38
+ } catch (err) {
39
+ console.warn('[rumen] failed to create pg pool:', err.message);
40
+ _rumenPoolFailed = true;
41
+ return null;
42
+ }
43
+ }
16
44
 
17
45
  const { SessionManager } = require('./session');
18
46
  const { initDatabase, logCommand, getSessionHistory, getProjectSessions } = require('./database');
@@ -171,9 +199,13 @@ function createServer(config) {
171
199
  rag.onStatusChanged(sess, oldStatus, newStatus);
172
200
  };
173
201
 
174
- // Proactive Mnestra queries on error — fire-and-forget, respects rag.enabled
202
+ // Proactive Mnestra queries on error — fire-and-forget.
203
+ // Independent of rag.enabled — the push loop (rag.js) and the Flashback
204
+ // bridge (mnestra-bridge) are separate systems. rag.enabled gates only
205
+ // the telemetry push loop. Flashback has its own error handling via
206
+ // the catch below and should fire whenever the Mnestra bridge is
207
+ // configured, regardless of the push-loop flag.
175
208
  session.onErrorDetected = (sess, ctx) => {
176
- if (!rag.enabled) return;
177
209
  const question = `${sess.meta.type} error ${ctx.lastCommand || ''} ${ctx.tail || ''}`.trim();
178
210
  mnestraBridge.queryMnestra({
179
211
  question,
@@ -415,6 +447,146 @@ function createServer(config) {
415
447
  });
416
448
  });
417
449
 
450
+ // ==================== Rumen insights (Sprint 4 T2) ====================
451
+ // Read-only access to rumen_insights + rumen_jobs in the petvetbid Postgres
452
+ // instance. Contract frozen in docs/sprint-4-rumen-integration/API-CONTRACT.md.
453
+
454
+ function rumenUnreachable(res) {
455
+ return res.status(503).json({ error: 'rumen database unreachable' });
456
+ }
457
+
458
+ // GET /api/rumen/insights
459
+ app.get('/api/rumen/insights', async (req, res) => {
460
+ const pool = getRumenPool();
461
+ if (!pool) {
462
+ return res.json({ insights: [], total: 0, enabled: false });
463
+ }
464
+
465
+ let limit = parseInt(req.query.limit, 10);
466
+ if (!Number.isFinite(limit)) limit = 20;
467
+ limit = Math.max(1, Math.min(100, limit));
468
+
469
+ const project = typeof req.query.project === 'string' && req.query.project.trim()
470
+ ? req.query.project.trim() : null;
471
+ const since = typeof req.query.since === 'string' && !Number.isNaN(Date.parse(req.query.since))
472
+ ? new Date(req.query.since).toISOString() : null;
473
+ const unseen = typeof req.query.unseen === 'string' &&
474
+ /^(1|true|yes)$/i.test(req.query.unseen);
475
+
476
+ const where = [];
477
+ const params = [];
478
+ if (project) { params.push(project); where.push(`$${params.length} = ANY(projects)`); }
479
+ if (since) { params.push(since); where.push(`created_at >= $${params.length}`); }
480
+ if (unseen) { where.push(`acted_upon = FALSE`); }
481
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
482
+
483
+ try {
484
+ const countSql = `SELECT COUNT(*)::int AS n FROM rumen_insights ${whereSql}`;
485
+ const listParams = params.slice();
486
+ listParams.push(limit);
487
+ const listSql =
488
+ `SELECT id, insight_text, confidence, projects, source_memory_ids, created_at, acted_upon
489
+ FROM rumen_insights
490
+ ${whereSql}
491
+ ORDER BY created_at DESC
492
+ LIMIT $${listParams.length}`;
493
+
494
+ const [countRes, listRes] = await Promise.all([
495
+ pool.query(countSql, params),
496
+ pool.query(listSql, listParams)
497
+ ]);
498
+
499
+ const insights = listRes.rows.map((r) => ({
500
+ id: r.id,
501
+ insight_text: r.insight_text,
502
+ confidence: r.confidence == null ? 0 : Number(r.confidence),
503
+ projects: r.projects || [],
504
+ source_memory_ids: r.source_memory_ids || [],
505
+ created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
506
+ acted_upon: !!r.acted_upon
507
+ }));
508
+
509
+ res.json({ insights, total: countRes.rows[0]?.n || 0 });
510
+ } catch (err) {
511
+ console.warn('[rumen] GET /insights failed:', err.message);
512
+ return rumenUnreachable(res);
513
+ }
514
+ });
515
+
516
+ // GET /api/rumen/status
517
+ app.get('/api/rumen/status', async (req, res) => {
518
+ const pool = getRumenPool();
519
+ if (!pool) return res.json({ enabled: false });
520
+
521
+ try {
522
+ const jobSql =
523
+ `SELECT id, status, completed_at, sessions_processed, insights_generated
524
+ FROM rumen_jobs
525
+ ORDER BY started_at DESC
526
+ LIMIT 1`;
527
+ const insightSql =
528
+ `SELECT
529
+ COUNT(*)::int AS total,
530
+ COUNT(*) FILTER (WHERE acted_upon = FALSE)::int AS unseen,
531
+ MAX(created_at) AS latest
532
+ FROM rumen_insights`;
533
+
534
+ const [jobRes, insightRes] = await Promise.all([
535
+ pool.query(jobSql),
536
+ pool.query(insightSql)
537
+ ]);
538
+
539
+ const job = jobRes.rows[0] || null;
540
+ const stat = insightRes.rows[0] || { total: 0, unseen: 0, latest: null };
541
+
542
+ res.json({
543
+ enabled: true,
544
+ last_job_id: job ? job.id : null,
545
+ last_job_status: job ? job.status : null,
546
+ last_job_completed_at: job && job.completed_at
547
+ ? (job.completed_at instanceof Date ? job.completed_at.toISOString() : job.completed_at)
548
+ : null,
549
+ last_job_sessions_processed: job ? (job.sessions_processed || 0) : 0,
550
+ last_job_insights_generated: job ? (job.insights_generated || 0) : 0,
551
+ total_insights: stat.total || 0,
552
+ unseen_insights: stat.unseen || 0,
553
+ latest_insight_at: stat.latest
554
+ ? (stat.latest instanceof Date ? stat.latest.toISOString() : stat.latest)
555
+ : null
556
+ });
557
+ } catch (err) {
558
+ console.warn('[rumen] GET /status failed:', err.message);
559
+ return rumenUnreachable(res);
560
+ }
561
+ });
562
+
563
+ // POST /api/rumen/insights/:id/seen
564
+ app.post('/api/rumen/insights/:id/seen', async (req, res) => {
565
+ const pool = getRumenPool();
566
+ if (!pool) return res.status(503).json({ error: 'rumen not configured' });
567
+
568
+ const id = req.params.id;
569
+ if (!UUID_RE.test(id)) {
570
+ return res.status(400).json({ error: 'invalid insight id' });
571
+ }
572
+
573
+ try {
574
+ const result = await pool.query(
575
+ `UPDATE rumen_insights SET acted_upon = TRUE WHERE id = $1
576
+ RETURNING id, acted_upon`,
577
+ [id]
578
+ );
579
+ if (result.rowCount === 0) {
580
+ return res.status(404).json({ error: 'insight not found' });
581
+ }
582
+ const row = result.rows[0];
583
+ res.json({ id: row.id, acted_upon: !!row.acted_upon });
584
+ } catch (err) {
585
+ console.warn('[rumen] POST /insights/:id/seen failed:', err.message);
586
+ return rumenUnreachable(res);
587
+ }
588
+ });
589
+
418
590
  // POST /api/ai/query - query Mnestra memory via the bridge (direct|webhook|mcp)
419
591
  app.post('/api/ai/query', async (req, res) => {
420
592
  let { question, sessionId, project } = req.body;
@@ -51,7 +51,11 @@ const PATTERNS = {
51
51
  command: /^[\$#%❯>]\s+(.+)$/m
52
52
  },
53
53
  // Broad error markers across shells, compilers, scripts, and HTTP servers.
54
- error: /\b(error|Error|ERROR|exception|Exception|Traceback|fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|\b5\d\d\b)\b/
54
+ // Includes the literal "No such file or directory" phrase because many Unix
55
+ // tools (cat, ls, cd, rm, etc.) report filesystem misses in plain English
56
+ // without ever emitting the ENOENT errno code. Flagged as a gap by Rumen's
57
+ // first production kickstart insight on 2026-04-15.
58
+ error: /\b(error|Error|ERROR|exception|Exception|Traceback|fatal|FATAL|segmentation fault|panic|EACCES|ECONNREFUSED|ENOENT|command not found|undefined reference|cannot find module|failed with exit code|No such file or directory|Permission denied|\b5\d\d\b)\b/
55
59
  };
56
60
 
57
61
  class Session {
@@ -20,7 +20,12 @@
20
20
  // @ts-ignore Deno std import resolved at runtime.
21
21
  import { serve } from 'https://deno.land/std@0.224.0/http/server.ts';
22
22
  // @ts-ignore npm specifier resolved at runtime.
23
- import { runRumenJob, createPoolFromUrl } from 'npm:@jhizzard/rumen@0.1.0';
23
+ // NOTE: `__RUMEN_VERSION__` is a placeholder. `termdeck init --rumen` reads the
24
+ // current published version from the npm registry at deploy time and rewrites
25
+ // this line in a staged copy of the file before running `supabase functions
26
+ // deploy`. This source file on disk MUST keep the placeholder — do not commit
27
+ // a real version number here. See packages/cli/src/init-rumen.js.
28
+ import { runRumenJob, createPoolFromUrl } from 'npm:@jhizzard/rumen@__RUMEN_VERSION__';
24
29
 
25
30
  // @ts-ignore Deno global available at runtime.
26
31
  declare const Deno: { env: { get: (k: string) => string | undefined } };
@@ -1,9 +1,11 @@
1
- -- Rumen v0.1 schema
2
- -- Non-destructive: creates three new tables under the rumen_ namespace.
1
+ -- Rumen schema — self-healing migration.
2
+ -- Non-destructive: creates three new tables under the rumen_ namespace and
3
+ -- brings any pre-existing tables (from partial prior installs) up to the
4
+ -- current shape via ALTER TABLE ADD COLUMN IF NOT EXISTS.
3
5
  -- Does NOT modify or reference Mnestra's existing memory_items / memory_sessions tables.
4
6
  --
5
7
  -- Apply with:
6
- -- psql "$DIRECT_URL" -f migrations/001_rumen_tables.sql
8
+ -- psql "$DATABASE_URL" -f migrations/001_rumen_tables.sql
7
9
 
8
10
  BEGIN;
9
11
 
@@ -23,6 +25,21 @@ CREATE TABLE IF NOT EXISTS rumen_jobs (
23
25
  completed_at TIMESTAMPTZ
24
26
  );
25
27
 
28
+ -- Backfill columns for schema drift from earlier install attempts.
29
+ -- CREATE TABLE IF NOT EXISTS is a no-op on existing tables, so without this
30
+ -- block the subsequent CREATE INDEX statements would fail on columns that
31
+ -- never got added.
32
+ ALTER TABLE rumen_jobs
33
+ ADD COLUMN IF NOT EXISTS triggered_by TEXT,
34
+ ADD COLUMN IF NOT EXISTS status TEXT,
35
+ ADD COLUMN IF NOT EXISTS sessions_processed INTEGER NOT NULL DEFAULT 0,
36
+ ADD COLUMN IF NOT EXISTS insights_generated INTEGER NOT NULL DEFAULT 0,
37
+ ADD COLUMN IF NOT EXISTS questions_generated INTEGER NOT NULL DEFAULT 0,
38
+ ADD COLUMN IF NOT EXISTS error_message TEXT,
39
+ ADD COLUMN IF NOT EXISTS source_session_ids UUID[] NOT NULL DEFAULT '{}',
40
+ ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
41
+ ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
42
+
26
43
  CREATE INDEX IF NOT EXISTS idx_rumen_jobs_status
27
44
  ON rumen_jobs (status);
28
45
 
@@ -49,6 +66,15 @@ CREATE TABLE IF NOT EXISTS rumen_insights (
49
66
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
50
67
  );
51
68
 
69
+ ALTER TABLE rumen_insights
70
+ ADD COLUMN IF NOT EXISTS job_id UUID,
71
+ ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] NOT NULL DEFAULT '{}',
72
+ ADD COLUMN IF NOT EXISTS projects TEXT[] NOT NULL DEFAULT '{}',
73
+ ADD COLUMN IF NOT EXISTS insight_text TEXT,
74
+ ADD COLUMN IF NOT EXISTS confidence NUMERIC(4, 3) NOT NULL DEFAULT 0.000,
75
+ ADD COLUMN IF NOT EXISTS acted_upon BOOLEAN NOT NULL DEFAULT FALSE,
76
+ ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
77
+
52
78
  CREATE INDEX IF NOT EXISTS idx_rumen_insights_job_id
53
79
  ON rumen_insights (job_id);
54
80
 
@@ -78,6 +104,16 @@ CREATE TABLE IF NOT EXISTS rumen_questions (
78
104
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
79
105
  );
80
106
 
107
+ ALTER TABLE rumen_questions
108
+ ADD COLUMN IF NOT EXISTS job_id UUID,
109
+ ADD COLUMN IF NOT EXISTS session_id UUID,
110
+ ADD COLUMN IF NOT EXISTS question TEXT,
111
+ ADD COLUMN IF NOT EXISTS context TEXT,
112
+ ADD COLUMN IF NOT EXISTS asked_at TIMESTAMPTZ,
113
+ ADD COLUMN IF NOT EXISTS answered_at TIMESTAMPTZ,
114
+ ADD COLUMN IF NOT EXISTS answer TEXT,
115
+ ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
116
+
81
117
  CREATE INDEX IF NOT EXISTS idx_rumen_questions_job_id
82
118
  ON rumen_questions (job_id);
83
119