@jhizzard/termdeck 0.2.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,216 @@
1
+ // RAG integration - multi-layer memory system
2
+ // Layers: session → project → developer (cross-project)
3
+ // Syncs to Supabase tables with configurable namespaces
4
+
5
+ const { logRagEvent, getUnsyncedRagEvents, markRagEventsSynced } = require('./database');
6
+
7
+ class RAGIntegration {
8
+ constructor(config, db) {
9
+ this.config = config;
10
+ this.db = db;
11
+ this.supabaseUrl = config.rag?.supabaseUrl || null;
12
+ this.supabaseKey = config.rag?.supabaseKey || null;
13
+ this.enabled = !!(config.rag?.enabled && this.supabaseUrl && this.supabaseKey);
14
+ this.syncInterval = config.rag?.syncIntervalMs || 10000;
15
+ this._syncTimer = null;
16
+
17
+ // Table configuration matching Josh's multi-layer schema
18
+ this.tables = {
19
+ sessionMemory: config.rag?.tables?.session || 'engram_session_memory',
20
+ projectMemory: config.rag?.tables?.project || 'engram_project_memory',
21
+ developerMemory: config.rag?.tables?.developer || 'engram_developer_memory',
22
+ commandLog: config.rag?.tables?.commands || 'engram_commands'
23
+ };
24
+
25
+ if (this.enabled) {
26
+ this._startSync();
27
+ }
28
+ }
29
+
30
+ // Record an event locally (always works, even offline)
31
+ record(sessionId, eventType, payload, project) {
32
+ if (!this.db) return;
33
+
34
+ logRagEvent(this.db, sessionId, eventType, payload, project);
35
+
36
+ // Also attempt immediate push if enabled
37
+ if (this.enabled) {
38
+ this._pushEvent({
39
+ session_id: sessionId,
40
+ event_type: eventType,
41
+ payload,
42
+ project,
43
+ timestamp: new Date().toISOString()
44
+ }).catch(() => {}); // Silent fail, sync will retry
45
+ }
46
+ }
47
+
48
+ // Event types to record
49
+ onSessionCreated(session) {
50
+ this.record(session.id, 'session_created', {
51
+ type: session.meta.type,
52
+ command: session.meta.command,
53
+ cwd: session.meta.cwd,
54
+ reason: session.meta.reason
55
+ }, session.meta.project);
56
+ }
57
+
58
+ onCommandExecuted(session, command, outputSnippet) {
59
+ this.record(session.id, 'command_executed', {
60
+ command,
61
+ output_snippet: outputSnippet?.slice(0, 500), // Truncate for storage
62
+ type: session.meta.type
63
+ }, session.meta.project);
64
+ }
65
+
66
+ onStatusChanged(session, oldStatus, newStatus) {
67
+ this.record(session.id, 'status_changed', {
68
+ from: oldStatus,
69
+ to: newStatus,
70
+ detail: session.meta.statusDetail,
71
+ type: session.meta.type
72
+ }, session.meta.project);
73
+ }
74
+
75
+ onSessionEnded(session) {
76
+ this.record(session.id, 'session_ended', {
77
+ type: session.meta.type,
78
+ duration_ms: Date.now() - new Date(session.meta.createdAt).getTime(),
79
+ command_count: session.meta.lastCommands.length,
80
+ exit_code: session.meta.exitCode
81
+ }, session.meta.project);
82
+ }
83
+
84
+ onFileEdited(session, filepath, editType) {
85
+ this.record(session.id, 'file_edited', {
86
+ filepath,
87
+ edit_type: editType,
88
+ type: session.meta.type
89
+ }, session.meta.project);
90
+ }
91
+
92
+ // Push a single event to Supabase
93
+ async _pushEvent(event) {
94
+ if (!this.enabled) return;
95
+
96
+ const layer = this._determineLayer(event);
97
+ const table = this.tables[layer];
98
+
99
+ try {
100
+ const response = await fetch(`${this.supabaseUrl}/rest/v1/${table}`, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json',
104
+ 'apikey': this.supabaseKey,
105
+ 'Authorization': `Bearer ${this.supabaseKey}`,
106
+ 'Prefer': 'return=minimal'
107
+ },
108
+ body: JSON.stringify({
109
+ session_id: event.session_id,
110
+ event_type: event.event_type,
111
+ payload: typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload),
112
+ project: event.project,
113
+ timestamp: event.timestamp,
114
+ developer_id: this.config.rag?.developerId || 'default'
115
+ })
116
+ });
117
+
118
+ if (!response.ok) {
119
+ throw new Error(`Supabase responded ${response.status}`);
120
+ }
121
+ } catch (err) {
122
+ // Will be retried by sync loop
123
+ console.error('[engram] Push failed:', err.message);
124
+ }
125
+ }
126
+
127
+ // Determine which memory layer an event belongs to
128
+ _determineLayer(event) {
129
+ // File edits and significant commands → project memory (shared across sessions)
130
+ if (event.event_type === 'file_edited' || event.event_type === 'command_executed') {
131
+ return 'projectMemory';
132
+ }
133
+ // Session lifecycle → session memory
134
+ if (event.event_type === 'session_created' || event.event_type === 'session_ended') {
135
+ return 'sessionMemory';
136
+ }
137
+ // Status changes, errors → developer memory (cross-project patterns)
138
+ return 'developerMemory';
139
+ }
140
+
141
+ // Periodic sync of unsynced events
142
+ _startSync() {
143
+ this._syncTimer = setInterval(async () => {
144
+ try {
145
+ const events = getUnsyncedRagEvents(this.db);
146
+ if (events.length === 0) return;
147
+
148
+ const synced = [];
149
+ for (const event of events) {
150
+ try {
151
+ await this._pushEvent({
152
+ ...event,
153
+ payload: JSON.parse(event.payload)
154
+ });
155
+ synced.push(event.id);
156
+ } catch (err) {
157
+ console.error('[rag] sync push failed for event', event.id + ':', err);
158
+ break; // Stop on first failure, retry next cycle
159
+ }
160
+ }
161
+
162
+ if (synced.length > 0) {
163
+ markRagEventsSynced(this.db, synced);
164
+ }
165
+ } catch (err) {
166
+ console.error('[engram] Sync cycle error:', err.message);
167
+ }
168
+ }, this.syncInterval);
169
+ }
170
+
171
+ // Query cross-project context (for the prompt bar AI features)
172
+ async queryContext(query, options = {}) {
173
+ if (!this.enabled) return [];
174
+
175
+ const table = options.project
176
+ ? this.tables.projectMemory
177
+ : this.tables.developerMemory;
178
+
179
+ try {
180
+ // Uses Supabase's full-text search or pgvector if configured
181
+ const params = new URLSearchParams({
182
+ select: '*',
183
+ order: 'timestamp.desc',
184
+ limit: options.limit || 20
185
+ });
186
+
187
+ if (options.project) {
188
+ params.append('project', `eq.${options.project}`);
189
+ }
190
+
191
+ const response = await fetch(
192
+ `${this.supabaseUrl}/rest/v1/${table}?${params}`,
193
+ {
194
+ headers: {
195
+ 'apikey': this.supabaseKey,
196
+ 'Authorization': `Bearer ${this.supabaseKey}`
197
+ }
198
+ }
199
+ );
200
+
201
+ if (!response.ok) return [];
202
+ return await response.json();
203
+ } catch (err) {
204
+ console.error('[rag] queryContext failed:', err);
205
+ return [];
206
+ }
207
+ }
208
+
209
+ stop() {
210
+ if (this._syncTimer) {
211
+ clearInterval(this._syncTimer);
212
+ }
213
+ }
214
+ }
215
+
216
+ module.exports = { RAGIntegration };
@@ -0,0 +1,166 @@
1
+ // Session-log summarizer (T2.5 / Tier 1 feature).
2
+ // On session exit, writes a markdown session log to ~/.termdeck/sessions/.
3
+ // Optional LLM summary via Anthropic if ANTHROPIC_API_KEY is set.
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ let warnedNoKey = false;
10
+
11
+ function slugify(str) {
12
+ return String(str || 'session')
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9]+/g, '-')
15
+ .replace(/^-+|-+$/g, '')
16
+ .slice(0, 40) || 'session';
17
+ }
18
+
19
+ function buildMarkdown({ session, summary, commands, edits, errors }) {
20
+ const meta = session.meta;
21
+ const opened = meta.createdAt;
22
+ const closed = new Date().toISOString();
23
+ const lines = [];
24
+ lines.push('---');
25
+ lines.push(`session_id: ${session.id}`);
26
+ lines.push(`project: ${meta.project || ''}`);
27
+ lines.push(`type: ${meta.type}`);
28
+ lines.push(`opened_at: ${opened}`);
29
+ lines.push(`closed_at: ${closed}`);
30
+ lines.push(`command_count: ${commands.length}`);
31
+ if (meta.exitCode !== null && meta.exitCode !== undefined) {
32
+ lines.push(`exit_code: ${meta.exitCode}`);
33
+ }
34
+ lines.push('---');
35
+ lines.push('');
36
+ lines.push(`# TermDeck session ${session.id.slice(0, 8)} — ${meta.label || meta.type}`);
37
+ lines.push('');
38
+ lines.push('## What ran');
39
+ lines.push('');
40
+ if (commands.length === 0) {
41
+ lines.push('_(no commands recorded)_');
42
+ } else {
43
+ for (const c of commands) {
44
+ const ts = c.timestamp || c.created_at || '';
45
+ lines.push(`- \`${c.command}\`${ts ? ` — ${ts}` : ''}`);
46
+ }
47
+ }
48
+ lines.push('');
49
+ lines.push('## What was edited');
50
+ lines.push('');
51
+ if (edits.length === 0) {
52
+ lines.push('_(no edits detected)_');
53
+ } else {
54
+ for (const e of edits) lines.push(`- ${e}`);
55
+ }
56
+ lines.push('');
57
+ lines.push('## What errored');
58
+ lines.push('');
59
+ if (errors.length === 0) {
60
+ lines.push('_(no errors detected)_');
61
+ } else {
62
+ for (const e of errors) lines.push(`- ${e}`);
63
+ }
64
+ lines.push('');
65
+ if (summary) {
66
+ lines.push('## Summary');
67
+ lines.push('');
68
+ lines.push(summary);
69
+ lines.push('');
70
+ }
71
+ return lines.join('\n');
72
+ }
73
+
74
+ async function summarizeWithLLM({ session, commands, edits, errors, model, apiKey }) {
75
+ const prompt = [
76
+ `You are summarizing a TermDeck terminal session in 2-4 sentences.`,
77
+ `Session type: ${session.meta.type}`,
78
+ `Project: ${session.meta.project || 'untagged'}`,
79
+ `Commands (${commands.length}): ${commands.slice(-20).map((c) => c.command).join(' | ')}`,
80
+ `Edits: ${edits.slice(-10).join(' | ') || 'none'}`,
81
+ `Errors: ${errors.slice(-5).join(' | ') || 'none'}`,
82
+ `Write a concise summary of what the user accomplished, what broke, and what state the session ended in.`
83
+ ].join('\n');
84
+
85
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
86
+ method: 'POST',
87
+ headers: {
88
+ 'Content-Type': 'application/json',
89
+ 'x-api-key': apiKey,
90
+ 'anthropic-version': '2023-06-01'
91
+ },
92
+ body: JSON.stringify({
93
+ model: model || 'claude-haiku-4-5',
94
+ max_tokens: 400,
95
+ messages: [{ role: 'user', content: prompt }]
96
+ })
97
+ });
98
+ if (!res.ok) {
99
+ const body = await res.text();
100
+ throw new Error(`Anthropic API ${res.status}: ${body}`);
101
+ }
102
+ const data = await res.json();
103
+ const block = data.content?.[0];
104
+ return block?.text?.trim() || '';
105
+ }
106
+
107
+ function writeSessionLog({ session, config, db, getSessionHistory }) {
108
+ try {
109
+ const enabled = config.sessionLogs?.enabled === true || process.env.TERMDECK_SESSION_LOGS === '1';
110
+ if (!enabled) return;
111
+
112
+ const sessionsDir = path.join(os.homedir(), '.termdeck', 'sessions');
113
+ fs.mkdirSync(sessionsDir, { recursive: true });
114
+
115
+ const commands = (db && typeof getSessionHistory === 'function')
116
+ ? getSessionHistory(db, session.id).slice().reverse()
117
+ : session.meta.lastCommands.slice();
118
+
119
+ // Heuristics: classify commands as edits or errors (also scan command output snippets)
120
+ const edits = [];
121
+ const errors = [];
122
+ for (const c of commands) {
123
+ const text = `${c.command || ''} ${c.output_snippet || ''}`;
124
+ if (/\b(vim|nano|nvim|code|edit|Edit |Create |Update |Delete )\b/.test(text)) {
125
+ edits.push(c.command);
126
+ }
127
+ if (/\b(error|Error|fatal|Traceback|panic|ECONN|ENOENT)\b/.test(text)) {
128
+ errors.push(c.command);
129
+ }
130
+ }
131
+
132
+ const apiKey = config.rag?.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
133
+ const model = config.sessionLogs?.summaryModel || 'claude-haiku-4-5';
134
+
135
+ const finishWrite = (summary) => {
136
+ const markdown = buildMarkdown({ session, summary, commands, edits, errors });
137
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
138
+ const slug = slugify(session.meta.label || session.meta.type);
139
+ const filename = `${ts}-${session.id.slice(0, 8)}-${slug}.md`;
140
+ const filepath = path.join(sessionsDir, filename);
141
+ fs.writeFileSync(filepath, markdown, 'utf-8');
142
+ console.log(`[session-logger] wrote ${filepath}`);
143
+ };
144
+
145
+ if (!apiKey) {
146
+ if (!warnedNoKey) {
147
+ console.warn('[session-logger] ANTHROPIC_API_KEY not set — writing logs without LLM summary');
148
+ warnedNoKey = true;
149
+ }
150
+ finishWrite(null);
151
+ return;
152
+ }
153
+
154
+ // Fire-and-forget: do not block session teardown
155
+ summarizeWithLLM({ session, commands, edits, errors, model, apiKey })
156
+ .then((summary) => finishWrite(summary))
157
+ .catch((err) => {
158
+ console.warn('[session-logger] LLM summary failed, writing without summary:', err.message);
159
+ finishWrite(null);
160
+ });
161
+ } catch (err) {
162
+ console.error('[session-logger] writeSessionLog failed:', err);
163
+ }
164
+ }
165
+
166
+ module.exports = { writeSessionLog };