termify-agent 1.0.39 → 1.0.41

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 (37) hide show
  1. package/dist/agent.d.ts.map +1 -1
  2. package/dist/agent.js +2 -0
  3. package/dist/agent.js.map +1 -1
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +55 -0
  6. package/dist/auth.js.map +1 -1
  7. package/dist/dashboard.d.ts.map +1 -1
  8. package/dist/dashboard.js +26 -2
  9. package/dist/dashboard.js.map +1 -1
  10. package/dist/index.js +5 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/pty-manager.d.ts +19 -0
  13. package/dist/pty-manager.d.ts.map +1 -1
  14. package/dist/pty-manager.js +111 -0
  15. package/dist/pty-manager.js.map +1 -1
  16. package/dist/setup.d.ts +15 -0
  17. package/dist/setup.d.ts.map +1 -0
  18. package/dist/setup.js +603 -0
  19. package/dist/setup.js.map +1 -0
  20. package/hooks/termify-response.js +151 -124
  21. package/hooks/termify-sync.js +165 -116
  22. package/mcp/memsearch-mcp-server.mjs +149 -0
  23. package/package.json +3 -2
  24. package/plugins/context7/.claude-plugin/plugin.json +7 -0
  25. package/plugins/context7/.mcp.json +6 -0
  26. package/plugins/memsearch/.claude-plugin/plugin.json +5 -0
  27. package/plugins/memsearch/README.md +762 -0
  28. package/plugins/memsearch/hooks/common.sh +151 -0
  29. package/plugins/memsearch/hooks/hooks.json +50 -0
  30. package/plugins/memsearch/hooks/parse-transcript.sh +117 -0
  31. package/plugins/memsearch/hooks/session-end.sh +9 -0
  32. package/plugins/memsearch/hooks/session-start.sh +119 -0
  33. package/plugins/memsearch/hooks/stop.sh +117 -0
  34. package/plugins/memsearch/hooks/user-prompt-submit.sh +21 -0
  35. package/plugins/memsearch/scripts/derive-collection.sh +50 -0
  36. package/plugins/memsearch/skills/memory-recall/SKILL.md +42 -0
  37. package/scripts/postinstall.js +21 -483
@@ -2,13 +2,10 @@
2
2
  /**
3
3
  * Termify Response Sync Hook (Stop / AfterAgent)
4
4
  *
5
- * Syncs the assistant's last response to the Termify conversation
5
+ * Syncs the assistant's last response to all Termify endpoints
6
6
  * when Claude Code / Gemini finishes its turn.
7
7
  *
8
- * Also sends remaining thinking blocks and final text as stream events
9
- * so the full reasoning process is visible in the Termify UI.
10
- *
11
- * Uses transcript_path hash to find the session file created by termify-sync.js.
8
+ * Supports multiple endpoints (local + prod) sends to all in parallel.
12
9
  *
13
10
  * Token: ~/.termify/config.json
14
11
  * Session: /tmp/termify-session-{hash}.json
@@ -18,79 +15,101 @@ const fs = require('fs');
18
15
  const path = require('path');
19
16
  const crypto = require('crypto');
20
17
 
21
- /**
22
- * Fix Gemini CLI streaming bug where prompt_response contains the response
23
- * text duplicated (full_response + partial_repeat_from_midpoint).
24
- * Detects and removes the duplicated tail.
25
- */
26
18
  function deduplicateGeminiResponse(text) {
27
19
  if (!text || text.length < 200) return text;
28
-
29
20
  const len = text.length;
30
-
31
- // Scan for a split point where the tail matches earlier content
32
21
  for (let splitAt = Math.floor(len * 0.35); splitAt <= Math.floor(len * 0.65); splitAt++) {
33
22
  const tail = text.slice(splitAt);
34
-
35
- // The duplicated portion must be significant (>25% of total)
36
23
  if (tail.length < len * 0.25) continue;
37
-
38
- // Use first 80 chars of tail as search needle
39
24
  const needleLen = Math.min(80, tail.length);
40
25
  const needle = tail.slice(0, needleLen);
41
-
42
- // Search for this needle BEFORE the split point
43
26
  const idx = text.indexOf(needle);
44
27
  if (idx >= 0 && idx < splitAt) {
45
- // Verify longer match (at least 100 chars must match)
46
28
  const matchLen = Math.min(tail.length, len - idx, 300);
47
29
  if (matchLen > 100 && tail.slice(0, matchLen) === text.slice(idx, idx + matchLen)) {
48
30
  return text.slice(0, splitAt).trimEnd();
49
31
  }
50
32
  }
51
33
  }
52
-
53
34
  return text;
54
35
  }
55
36
 
56
- /**
57
- * Extract ALL thinking blocks from the current assistant turn in the transcript.
58
- */
59
- function extractAllThinking(transcriptPath) {
37
+ function extractTranscriptEvents(transcriptPath) {
38
+ const result = { thinkings: [], toolEvents: [] };
60
39
  try {
61
40
  const data = fs.readFileSync(transcriptPath, 'utf-8');
62
41
  const lines = data.trim().split('\n');
63
-
64
- const thinkings = [];
42
+ const entries = [];
65
43
  for (let i = lines.length - 1; i >= 0; i--) {
66
44
  try {
67
45
  const entry = JSON.parse(lines[i]);
68
- // Stop at user message (end of current assistant turn)
69
46
  if (entry.type === 'user' && !entry.toolUseResult && !entry.sourceToolAssistantUUID) break;
70
- if (entry.type === 'assistant' && entry.message) {
71
- let msg;
72
- try {
73
- msg = typeof entry.message === 'string' ? JSON.parse(entry.message) : entry.message;
74
- } catch {
75
- // message might use Python-style single quotes, try eval-like parse
76
- try {
77
- msg = typeof entry.message === 'string' ? JSON.parse(entry.message.replace(/'/g, '"')) : entry.message;
78
- } catch { continue; }
79
- }
80
- const content = msg?.content || [];
81
- for (const block of content) {
82
- if (block?.type === 'thinking' && block.thinking) {
83
- thinkings.unshift(block.thinking);
47
+ entries.unshift(entry);
48
+ } catch {}
49
+ }
50
+ for (const entry of entries) {
51
+ if (entry.type !== 'assistant' || !entry.message) continue;
52
+ let msg;
53
+ try {
54
+ msg = typeof entry.message === 'string' ? JSON.parse(entry.message) : entry.message;
55
+ } catch {
56
+ try {
57
+ msg = typeof entry.message === 'string' ? JSON.parse(entry.message.replace(/'/g, '"')) : entry.message;
58
+ } catch { continue; }
59
+ }
60
+ const content = msg?.content;
61
+ if (!Array.isArray(content)) continue;
62
+ for (const block of content) {
63
+ if (block?.type === 'thinking' && block.thinking) {
64
+ result.thinkings.push(block.thinking);
65
+ }
66
+ if (block?.type === 'tool_use' && block.name) {
67
+ const toolEvent = { type: 'tool_use', tool: block.name };
68
+ if (block.input) {
69
+ const displayKeys = ['file_path', 'command', 'pattern', 'query', 'description', 'url', 'prompt', 'skill', 'subagent_type', 'old_string', 'notebook_path'];
70
+ const slim = {};
71
+ for (const k of displayKeys) {
72
+ if (block.input[k] != null) {
73
+ const v = String(block.input[k]);
74
+ slim[k] = v.length > 200 ? v.substring(0, 197) + '...' : v;
75
+ }
84
76
  }
77
+ if (Object.keys(slim).length > 0) toolEvent.args = slim;
78
+ }
79
+ result.toolEvents.push(toolEvent);
80
+ if (block.name === 'AskUserQuestion' && block.input?.questions) {
81
+ result.toolEvents.push({
82
+ type: 'question',
83
+ questionId: block.id || `q-${Date.now()}`,
84
+ questions: block.input.questions,
85
+ });
85
86
  }
86
87
  }
87
- } catch {}
88
+ }
88
89
  }
90
+ } catch {}
91
+ return result;
92
+ }
89
93
 
90
- return thinkings;
91
- } catch {
92
- return [];
93
- }
94
+ /**
95
+ * Read endpoints from config. Supports both old and new format.
96
+ */
97
+ function readEndpoints() {
98
+ try {
99
+ const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
100
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
101
+ if (Array.isArray(config.endpoints) && config.endpoints.length > 0) {
102
+ return config.endpoints.map(ep => ({
103
+ name: ep.name || 'default',
104
+ apiUrl: ep.apiUrl,
105
+ token: ep.token,
106
+ })).filter(ep => ep.apiUrl && ep.token);
107
+ }
108
+ if (config.token) {
109
+ return [{ name: 'default', apiUrl: config.apiUrl || 'http://localhost:3001', token: config.token }];
110
+ }
111
+ } catch {}
112
+ return [];
94
113
  }
95
114
 
96
115
  // Skip sync when running inside a Termify-managed chat terminal
@@ -109,7 +128,6 @@ process.stdin.on('end', async () => {
109
128
  return;
110
129
  }
111
130
 
112
- // Prevent infinite loops
113
131
  if (hookData?.stop_hook_active) {
114
132
  process.exit(0);
115
133
  return;
@@ -117,8 +135,6 @@ process.stdin.on('end', async () => {
117
135
 
118
136
  const transcriptPath = hookData?.transcript_path;
119
137
  const hookEvent = hookData?.hook_event_name || '';
120
-
121
- // Claude Code: last_assistant_message, Gemini CLI: prompt_response
122
138
  let lastMessage = hookData?.last_assistant_message || hookData?.prompt_response;
123
139
 
124
140
  if (!transcriptPath || !lastMessage) {
@@ -126,15 +142,11 @@ process.stdin.on('end', async () => {
126
142
  return;
127
143
  }
128
144
 
129
- // Trim leading whitespace from Gemini responses (they often start with spaces)
130
145
  lastMessage = lastMessage.trimStart();
131
-
132
- // Fix Gemini CLI streaming duplication bug
133
146
  if (hookEvent === 'AfterAgent') {
134
147
  lastMessage = deduplicateGeminiResponse(lastMessage);
135
148
  }
136
149
 
137
- // Find session file
138
150
  const sessionHash = crypto.createHash('md5').update(transcriptPath).digest('hex').substring(0, 12);
139
151
  const sessionFile = path.join('/tmp', `termify-session-${sessionHash}.json`);
140
152
 
@@ -146,99 +158,114 @@ process.stdin.on('end', async () => {
146
158
  return;
147
159
  }
148
160
 
149
- if (!session.conversationId) {
150
- process.exit(0);
151
- return;
161
+ // Legacy single-endpoint session: convert to multi-endpoint format
162
+ if (session.conversationId && !session.endpoints) {
163
+ const endpoints = readEndpoints();
164
+ const defaultName = endpoints[0]?.name || 'default';
165
+ session.endpoints = {
166
+ [defaultName]: {
167
+ conversationId: session.conversationId,
168
+ assistantMessageId: session.assistantMessageId,
169
+ }
170
+ };
152
171
  }
153
172
 
154
- // Read Termify config
155
- let config;
156
- try {
157
- const configPath = path.join(process.env.HOME || '', '.termify', 'config.json');
158
- config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
159
- } catch {
173
+ if (!session.endpoints || Object.keys(session.endpoints).length === 0) {
160
174
  process.exit(0);
161
175
  return;
162
176
  }
163
177
 
164
- if (!config.token) {
178
+ const endpoints = readEndpoints();
179
+ if (endpoints.length === 0) {
165
180
  process.exit(0);
166
181
  return;
167
182
  }
168
183
 
169
- const apiUrl = config.apiUrl || 'http://localhost:3001';
184
+ const { thinkings, toolEvents } = extractTranscriptEvents(transcriptPath);
185
+ const alreadySent = session.thinkingsSent || 0;
170
186
 
171
- const headers = {
172
- 'Content-Type': 'application/json',
173
- 'Authorization': `Bearer ${config.token}`,
174
- };
187
+ /**
188
+ * Sync response to a single endpoint.
189
+ */
190
+ async function syncResponseToEndpoint(ep) {
191
+ const epState = session.endpoints[ep.name];
192
+ if (!epState?.conversationId) return;
175
193
 
176
- // CRITICAL PATH FIRST: complete message + set idle (atomic DB transaction).
177
- // This must run before anything else so the conversation doesn't get stuck
178
- // if Claude Code kills this hook process early.
179
- try {
180
- if (session.assistantMessageId) {
181
- const completeRes = await fetch(
182
- `${apiUrl}/api/chat/conversations/${session.conversationId}/messages/${session.assistantMessageId}/complete`,
183
- {
184
- method: 'POST',
185
- headers,
186
- body: JSON.stringify({ content: lastMessage }),
187
- signal: AbortSignal.timeout(5000),
188
- }
189
- );
190
- if (!completeRes.ok) throw new Error('complete failed');
191
- } else {
192
- // No streaming message — create a completed one + set idle in parallel
194
+ const { conversationId, assistantMessageId } = epState;
195
+ const headers = {
196
+ 'Content-Type': 'application/json',
197
+ 'Authorization': `Bearer ${ep.token}`,
198
+ };
199
+
200
+ // CRITICAL: complete message + set idle
201
+ try {
202
+ if (assistantMessageId) {
203
+ const res = await fetch(
204
+ `${ep.apiUrl}/api/chat/conversations/${conversationId}/messages/${assistantMessageId}/complete`,
205
+ {
206
+ method: 'POST', headers,
207
+ body: JSON.stringify({ content: lastMessage }),
208
+ signal: AbortSignal.timeout(5000),
209
+ }
210
+ );
211
+ if (!res.ok) throw new Error('complete failed');
212
+ } else {
213
+ await Promise.allSettled([
214
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}/messages`, {
215
+ method: 'POST', headers,
216
+ body: JSON.stringify({ role: 'assistant', content: lastMessage }),
217
+ signal: AbortSignal.timeout(5000),
218
+ }),
219
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}`, {
220
+ method: 'PATCH', headers,
221
+ body: JSON.stringify({ status: 'idle' }),
222
+ signal: AbortSignal.timeout(3000),
223
+ }),
224
+ ]);
225
+ }
226
+ } catch {
227
+ // Fallback
193
228
  await Promise.allSettled([
194
- fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}/messages`, {
195
- method: 'POST',
196
- headers,
229
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}/messages`, {
230
+ method: 'POST', headers,
197
231
  body: JSON.stringify({ role: 'assistant', content: lastMessage }),
198
232
  signal: AbortSignal.timeout(5000),
199
233
  }),
200
- fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}`, {
201
- method: 'PATCH',
202
- headers,
234
+ fetch(`${ep.apiUrl}/api/chat/conversations/${conversationId}`, {
235
+ method: 'PATCH', headers,
203
236
  body: JSON.stringify({ status: 'idle' }),
204
237
  signal: AbortSignal.timeout(3000),
205
238
  }),
206
239
  ]);
207
240
  }
208
- } catch {
209
- // /complete failed — fallback: create message + PATCH idle in parallel
210
- await Promise.allSettled([
211
- fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}/messages`, {
212
- method: 'POST',
213
- headers,
214
- body: JSON.stringify({ role: 'assistant', content: lastMessage }),
215
- signal: AbortSignal.timeout(5000),
216
- }),
217
- fetch(`${apiUrl}/api/chat/conversations/${session.conversationId}`, {
218
- method: 'PATCH',
219
- headers,
220
- body: JSON.stringify({ status: 'idle' }),
221
- signal: AbortSignal.timeout(3000),
222
- }),
223
- ]);
224
- }
225
241
 
226
- // NON-CRITICAL: Send stream events for real-time UI update.
227
- // If this fails, the message is already completed in DB — no harm done.
228
- if (session.assistantMessageId) {
229
- const eventsUrl = `${apiUrl}/api/chat/conversations/${session.conversationId}/messages/${session.assistantMessageId}/events`;
230
- fetch(eventsUrl, {
231
- method: 'POST',
232
- headers,
233
- body: JSON.stringify({
234
- events: [
235
- { type: 'text', content: lastMessage },
236
- { type: 'status', status: 'done' },
237
- ],
238
- }),
239
- signal: AbortSignal.timeout(3000),
240
- }).catch(() => {});
242
+ // NON-CRITICAL: stream events
243
+ if (assistantMessageId) {
244
+ const eventsUrl = `${ep.apiUrl}/api/chat/conversations/${conversationId}/messages/${assistantMessageId}/events`;
245
+ const events = [];
246
+ for (let i = alreadySent; i < thinkings.length; i++) {
247
+ events.push({ type: 'thinking', content: thinkings[i] });
248
+ }
249
+ for (const te of toolEvents) {
250
+ events.push(te);
251
+ }
252
+ events.push({ type: 'text', content: lastMessage });
253
+ events.push({ type: 'status', status: 'done' });
254
+
255
+ try {
256
+ await fetch(eventsUrl, {
257
+ method: 'POST', headers,
258
+ body: JSON.stringify({ events }),
259
+ signal: AbortSignal.timeout(5000),
260
+ });
261
+ } catch {}
262
+ }
241
263
  }
242
264
 
265
+ // Sync all endpoints in parallel
266
+ await Promise.allSettled(
267
+ endpoints.map(ep => syncResponseToEndpoint(ep))
268
+ );
269
+
243
270
  process.exit(0);
244
271
  });