@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.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/config/config.example.yaml +58 -0
- package/config/secrets.env.example +17 -0
- package/package.json +65 -0
- package/packages/cli/src/index.js +101 -0
- package/packages/client/public/index.html +3444 -0
- package/packages/server/src/config.js +308 -0
- package/packages/server/src/database.js +130 -0
- package/packages/server/src/engram-bridge/index.js +232 -0
- package/packages/server/src/index.js +581 -0
- package/packages/server/src/rag.js +216 -0
- package/packages/server/src/session-logger.js +166 -0
- package/packages/server/src/session.js +421 -0
- package/packages/server/src/themes.js +250 -0
|
@@ -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 };
|