teleportation-cli 1.4.0 → 1.4.1

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,339 @@
1
+ /**
2
+ * Transcript request-pattern mining.
3
+ *
4
+ * Mines repeated request intents from normalized transcript events so
5
+ * routing/guidance systems can learn common user asks.
6
+ */
7
+
8
+ const DEFAULT_MIN_OCCURRENCES = 2;
9
+ const DEFAULT_TOP_K = 25;
10
+ const MAX_REQUEST_TEXT_LENGTH = 1000;
11
+ const MIN_TOKENS_PER_PATTERN = 3;
12
+
13
+ const STOP_WORDS = new Set([
14
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how', 'i',
15
+ 'in', 'is', 'it', 'me', 'my', 'of', 'on', 'or', 'our', 'please', 'that',
16
+ 'the', 'this', 'to', 'us', 'we', 'what', 'when', 'where', 'which', 'with',
17
+ 'you', 'your',
18
+ ]);
19
+
20
+ const TOKEN_ALIASES = new Map([
21
+ ['vs', 'versus'],
22
+ ['v', 'versus'],
23
+ ]);
24
+
25
+ const NOISE_PREFIXES = [
26
+ '[request interrupted by user',
27
+ '[request interrupted by user for tool use',
28
+ '[image:',
29
+ '<local-command-caveat>',
30
+ 'this session is being continued from a previous conversation',
31
+ 'base directory for this skill:',
32
+ ];
33
+
34
+ const NOISE_SUBSTRINGS = [
35
+ 'please continue the conversation from where we left off',
36
+ 'if you need specific details from before compaction',
37
+ 'source: /users/',
38
+ 'original 3024x1600',
39
+ ];
40
+
41
+ const NOISE_EXACT = new Set([
42
+ 'yes',
43
+ 'yes proceed',
44
+ 'proceed',
45
+ 'continue',
46
+ 'go',
47
+ 'ok proceed',
48
+ 'okay proceed',
49
+ 'lets merge',
50
+ 'let us merge',
51
+ ]);
52
+
53
+ function isLikelyNoiseText(text) {
54
+ if (typeof text !== 'string') return true;
55
+ const normalized = text.trim().toLowerCase();
56
+ if (!normalized) return true;
57
+
58
+ const compact = normalized.replace(/\s+/g, ' ').trim();
59
+
60
+ if (compact.length > MAX_REQUEST_TEXT_LENGTH) return true;
61
+ if (NOISE_EXACT.has(compact)) return true;
62
+ if (NOISE_PREFIXES.some((prefix) => compact.startsWith(prefix))) return true;
63
+ if (NOISE_SUBSTRINGS.some((fragment) => compact.includes(fragment))) return true;
64
+ return false;
65
+ }
66
+
67
+ function toStem(token) {
68
+ if (token.endsWith('ing') && token.length > 5) return token.slice(0, -3);
69
+ if (token.endsWith('ed') && token.length > 4) return token.slice(0, -2);
70
+ if (token.endsWith('es') && token.length > 4) return token.slice(0, -2);
71
+ if (token.endsWith('s') && token.length > 3) return token.slice(0, -1);
72
+ return token;
73
+ }
74
+
75
+ function tokenize(text) {
76
+ if (typeof text !== 'string') return [];
77
+ return text
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9\s]/g, ' ')
80
+ .split(/\s+/)
81
+ .map((token) => token.trim())
82
+ .filter(Boolean)
83
+ .map((token) => TOKEN_ALIASES.get(token) || token)
84
+ .map(toStem)
85
+ .filter((token) => token.length >= 3 && !STOP_WORDS.has(token));
86
+ }
87
+
88
+ function extractRequestText(event) {
89
+ if (!event || typeof event !== 'object') return null;
90
+ const metadata = event.metadata || {};
91
+
92
+ const fields = [
93
+ metadata.user_prompt,
94
+ metadata.user_message,
95
+ metadata.prompt,
96
+ metadata.instruction,
97
+ metadata.query,
98
+ metadata.task_prompt,
99
+ ];
100
+
101
+ for (const value of fields) {
102
+ if (typeof value !== 'string') continue;
103
+ const trimmed = value.trim();
104
+ if (!trimmed) continue;
105
+ if (isLikelyNoiseText(trimmed)) continue;
106
+ return trimmed;
107
+ }
108
+
109
+ return null;
110
+ }
111
+
112
+ function classifyIntent(tokens) {
113
+ const set = new Set(tokens);
114
+
115
+ if (set.has('skill') || set.has('workflow') || set.has('agent') || set.has('claude')) {
116
+ return 'skill-orchestration';
117
+ }
118
+ if (set.has('benchmark') || set.has('cost') || set.has('latency') || set.has('quality')) {
119
+ return 'benchmark-analysis';
120
+ }
121
+ if (set.has('route') || set.has('model') || set.has('harness') || set.has('codex') || set.has('gemini')) {
122
+ return 'routing-selection';
123
+ }
124
+ if (set.has('approval') || set.has('approve') || set.has('deny') || set.has('escalate') || set.has('risk')) {
125
+ return 'approval-policy';
126
+ }
127
+ if (set.has('fix') || set.has('error') || set.has('fail') || set.has('test') || set.has('bug')) {
128
+ return 'debugging';
129
+ }
130
+
131
+ return 'general-request';
132
+ }
133
+
134
+ function buildSignature(tokens) {
135
+ const unique = [];
136
+ const seen = new Set();
137
+
138
+ for (const token of tokens) {
139
+ if (seen.has(token)) continue;
140
+ seen.add(token);
141
+ unique.push(token);
142
+ if (unique.length >= 6) break;
143
+ }
144
+
145
+ return unique.join(' ');
146
+ }
147
+
148
+ function incrementCount(map, key) {
149
+ map.set(key, (map.get(key) || 0) + 1);
150
+ }
151
+
152
+ export function mineRequestPatterns(events, options = {}) {
153
+ if (!Array.isArray(events) || events.length === 0) return [];
154
+
155
+ const minOccurrences = Number.isInteger(options.minOccurrences)
156
+ ? Math.max(1, options.minOccurrences)
157
+ : DEFAULT_MIN_OCCURRENCES;
158
+ const topK = Number.isInteger(options.topK) ? Math.max(1, options.topK) : DEFAULT_TOP_K;
159
+
160
+ const buckets = new Map();
161
+
162
+ for (const event of events) {
163
+ const text = extractRequestText(event);
164
+ if (!text) continue;
165
+
166
+ const tokens = tokenize(text);
167
+ if (tokens.length < MIN_TOKENS_PER_PATTERN) continue;
168
+
169
+ const signature = buildSignature(tokens);
170
+ if (!signature) continue;
171
+
172
+ const existing = buckets.get(signature) || {
173
+ signature,
174
+ frequency: 0,
175
+ sample_text: text,
176
+ intents: new Map(),
177
+ task_categories: new Map(),
178
+ providers: new Map(),
179
+ };
180
+
181
+ existing.frequency += 1;
182
+ incrementCount(existing.intents, classifyIntent(tokens));
183
+ incrementCount(existing.task_categories, event.task_category || 'unknown');
184
+ if (event.provider) incrementCount(existing.providers, event.provider);
185
+
186
+ buckets.set(signature, existing);
187
+ }
188
+
189
+ const totalMatched = Array.from(buckets.values()).reduce((sum, bucket) => sum + bucket.frequency, 0);
190
+ if (totalMatched === 0) return [];
191
+
192
+ return Array.from(buckets.values())
193
+ .filter((bucket) => bucket.frequency >= minOccurrences)
194
+ .map((bucket) => {
195
+ const dominantIntent = Array.from(bucket.intents.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || 'general-request';
196
+ const confidence = Number((bucket.frequency / totalMatched).toFixed(4));
197
+
198
+ return {
199
+ signature: bucket.signature,
200
+ frequency: bucket.frequency,
201
+ confidence,
202
+ dominant_intent: dominantIntent,
203
+ sample_text: bucket.sample_text,
204
+ task_categories: Object.fromEntries(bucket.task_categories),
205
+ providers: Object.fromEntries(bucket.providers),
206
+ };
207
+ })
208
+ .sort((a, b) => {
209
+ if (b.frequency !== a.frequency) return b.frequency - a.frequency;
210
+ return a.signature.localeCompare(b.signature);
211
+ })
212
+ .slice(0, topK);
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Cost estimation
217
+ // ---------------------------------------------------------------------------
218
+
219
+ const MODEL_PRICES = {
220
+ 'claude-sonnet': { input: 3.00, output: 15.00 },
221
+ 'claude-opus': { input: 15.00, output: 75.00 },
222
+ 'gemini-2.5-pro': { input: 1.25, output: 10.00 },
223
+ 'o3': { input: 10.00, output: 40.00 },
224
+ 'o4-mini': { input: 1.10, output: 4.40 },
225
+ 'default': { input: 3.00, output: 15.00 },
226
+ };
227
+
228
+ function getModelPrice(modelName) {
229
+ if (typeof modelName !== 'string') return MODEL_PRICES['default'];
230
+ const lower = modelName.toLowerCase();
231
+ for (const key of Object.keys(MODEL_PRICES)) {
232
+ if (key === 'default') continue;
233
+ if (lower.startsWith(key)) return MODEL_PRICES[key];
234
+ }
235
+ return MODEL_PRICES['default'];
236
+ }
237
+
238
+ function computeSessionCost(messages) {
239
+ let cost_usd = 0;
240
+ let total_tokens = 0;
241
+
242
+ if (!Array.isArray(messages)) return { cost_usd: 0, total_tokens: 0 };
243
+
244
+ for (const msg of messages) {
245
+ const usage = msg?.usage || msg?.message?.usage;
246
+ if (!usage) continue;
247
+
248
+ const modelName = msg?.model || msg?.message?.model || '';
249
+ const prices = getModelPrice(modelName);
250
+
251
+ const inputTokens = usage.input_tokens || 0;
252
+ const outputTokens = usage.output_tokens || 0;
253
+
254
+ cost_usd += (inputTokens / 1_000_000) * prices.input;
255
+ cost_usd += (outputTokens / 1_000_000) * prices.output;
256
+ total_tokens += inputTokens + outputTokens;
257
+ }
258
+
259
+ return { cost_usd: Number(cost_usd.toFixed(6)), total_tokens };
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Steering metrics
264
+ // ---------------------------------------------------------------------------
265
+
266
+ const CORRECTION_INDICATORS = [
267
+ 'no ',
268
+ 'instead',
269
+ 'wait,',
270
+ 'actually,',
271
+ "don't",
272
+ 'stop,',
273
+ 'go back',
274
+ 'undo',
275
+ 'revert',
276
+ "that's wrong",
277
+ '[request interrupted',
278
+ ];
279
+
280
+ function classifyTurn(content) {
281
+ if (typeof content !== 'string') return 'progression';
282
+ const lower = content.toLowerCase();
283
+ for (const indicator of CORRECTION_INDICATORS) {
284
+ if (lower.includes(indicator)) return 'correction';
285
+ }
286
+ return 'progression';
287
+ }
288
+
289
+ function isToolResultBlock(content) {
290
+ if (!Array.isArray(content)) return false;
291
+ return content.some((block) => block?.type === 'tool_result');
292
+ }
293
+
294
+ function computeSteeringMetrics(messages) {
295
+ if (!Array.isArray(messages)) return { turns_total: 0, turns_human: 0, turns_correction: 0, steering_ratio: 0 };
296
+
297
+ let turns_total = 0;
298
+ let turns_human = 0;
299
+ let turns_correction = 0;
300
+
301
+ for (const msg of messages) {
302
+ const message = msg?.message || msg;
303
+ const role = message?.role;
304
+ if (role !== 'user') continue;
305
+
306
+ turns_total += 1;
307
+
308
+ const content = message?.content;
309
+ // Skip tool result turns (not human-authored)
310
+ if (isToolResultBlock(content)) continue;
311
+
312
+ turns_human += 1;
313
+
314
+ const text = typeof content === 'string' ? content : (Array.isArray(content)
315
+ ? content.filter((b) => b?.type === 'text').map((b) => b.text).join(' ')
316
+ : '');
317
+
318
+ if (classifyTurn(text) === 'correction') {
319
+ turns_correction += 1;
320
+ }
321
+ }
322
+
323
+ const steering_ratio = turns_human > 0 ? Number((turns_correction / turns_human).toFixed(2)) : 0;
324
+
325
+ return { turns_total, turns_human, turns_correction, steering_ratio };
326
+ }
327
+
328
+ export {
329
+ extractRequestText,
330
+ tokenize,
331
+ classifyIntent,
332
+ isLikelyNoiseText,
333
+ MODEL_PRICES,
334
+ getModelPrice,
335
+ computeSessionCost,
336
+ CORRECTION_INDICATORS,
337
+ classifyTurn,
338
+ computeSteeringMetrics,
339
+ };
@@ -258,12 +258,18 @@ export async function getCurrentModel() {
258
258
  }
259
259
 
260
260
  /**
261
- * Extract all session metadata for a given working directory
261
+ * Extract all session metadata for a given working directory.
262
+ * @param {string} cwd - Working directory path
263
+ * @param {object} [hookInput] - Optional raw hook input (used to pull Cursor-specific
264
+ * fields like model, is_background_agent, composer_mode directly from the event payload)
262
265
  */
263
- export async function extractSessionMetadata(cwd) {
266
+ export async function extractSessionMetadata(cwd, hookInput = null) {
264
267
  const systemInfo = getSystemInfo();
265
268
  const isGit = isGitRepo(cwd);
266
- const currentModel = await getCurrentModel();
269
+
270
+ // Prefer model from hook input (Cursor injects it in every event's common schema)
271
+ // over env vars and settings.json — more reliable for multi-client setups
272
+ const currentModel = hookInput?.model || await getCurrentModel();
267
273
 
268
274
  const metadata = {
269
275
  session_id: null, // Will be set by caller
@@ -274,16 +280,28 @@ export async function extractSessionMetadata(cwd) {
274
280
  commit_hash: isGit ? getCommitHash(cwd) : null,
275
281
  recent_commits: isGit ? getRecentCommits(cwd, 3) : [],
276
282
  current_task: isGit ? getCurrentTask(cwd) : null,
277
- current_model: currentModel, // Claude model being used in this session
283
+ current_model: currentModel,
278
284
  hostname: systemInfo.hostname,
279
285
  username: systemInfo.username,
280
286
  platform: systemInfo.platform,
281
287
  node_version: systemInfo.nodeVersion,
282
- is_git_repo: isGit
288
+ is_git_repo: isGit,
283
289
  // Note: started_at is intentionally NOT set here - it should only be set once
284
290
  // when the session is first created in the relay server, not on re-registration
285
291
  };
286
292
 
293
+ // Merge Cursor-specific fields when running inside Cursor IDE
294
+ if (hookInput?.cursor_version) {
295
+ metadata.client = 'cursor';
296
+ metadata.cursor_version = hookInput.cursor_version;
297
+ if (hookInput.is_background_agent !== undefined) {
298
+ metadata.is_background_agent = hookInput.is_background_agent;
299
+ }
300
+ if (hookInput.composer_mode) {
301
+ metadata.composer_mode = hookInput.composer_mode;
302
+ }
303
+ }
304
+
287
305
  return metadata;
288
306
  }
289
307
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Transcript Sync Lifecycle Management
3
+ *
4
+ * Thin adapter over @derivativelabs/agent-process.
5
+ * Provides start/stop/restart/status for transcript sync worker.
6
+ */
7
+
8
+ import { agentStart, agentStop, agentStatus } from '@derivativelabs/agent-process';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname, join } from 'path';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+
15
+ const AGENT_NAME = 'teleportation-transcript-sync';
16
+ const WORKER_SCRIPT = join(__dirname, 'worker.js');
17
+
18
+ export async function startTranscriptSync() {
19
+ const current = await getTranscriptSyncStatus();
20
+ if (current.running) {
21
+ throw new Error(`Transcript sync already running with PID ${current.pid}`);
22
+ }
23
+
24
+ const result = await agentStart({
25
+ name: AGENT_NAME,
26
+ script: WORKER_SCRIPT,
27
+ env: {
28
+ ...process.env,
29
+ TELEPORTATION_TRANSCRIPT_SYNC: 'true'
30
+ }
31
+ });
32
+
33
+ return {
34
+ pid: result.pid || null,
35
+ success: true
36
+ };
37
+ }
38
+
39
+ export async function stopTranscriptSync(options = {}) {
40
+ const current = await getTranscriptSyncStatus();
41
+ if (!current.running) {
42
+ return { success: true, forced: false };
43
+ }
44
+
45
+ try {
46
+ await agentStop(AGENT_NAME);
47
+ return { success: true, forced: false };
48
+ } catch (err) {
49
+ // Force handling is delegated to agent-process internals.
50
+ throw new Error(`Failed to stop transcript sync agent: ${err.message}`);
51
+ }
52
+ }
53
+
54
+ export async function restartTranscriptSync(options = {}) {
55
+ const { force = true } = options;
56
+ const current = await getTranscriptSyncStatus();
57
+ const wasRunning = current.running;
58
+
59
+ if (wasRunning) {
60
+ await stopTranscriptSync({ force });
61
+ }
62
+
63
+ const result = await startTranscriptSync();
64
+ return {
65
+ ...result,
66
+ wasRunning
67
+ };
68
+ }
69
+
70
+ export async function getTranscriptSyncStatus() {
71
+ try {
72
+ const status = await agentStatus(AGENT_NAME);
73
+ return {
74
+ running: status.state === 'online' || status.running || false,
75
+ pid: status.pid || null,
76
+ uptime: status.uptime || null
77
+ };
78
+ } catch {
79
+ return { running: false, pid: null, uptime: null };
80
+ }
81
+ }
82
+
83
+ export default {
84
+ startTranscriptSync,
85
+ stopTranscriptSync,
86
+ restartTranscriptSync,
87
+ getTranscriptSyncStatus
88
+ };
@@ -0,0 +1,45 @@
1
+ import { homedir, hostname } from 'os';
2
+ import { createHash } from 'crypto';
3
+ import { join } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+
6
+ function runGit(args, cwd) {
7
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 5000 });
8
+ if (result.status !== 0) {
9
+ return '';
10
+ }
11
+ return (result.stdout || '').trim();
12
+ }
13
+
14
+ function safeSlug(value) {
15
+ return (value || 'unknown-repo')
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9._-]+/g, '-')
18
+ .replace(/^-+|-+$/g, '')
19
+ .slice(0, 64) || 'unknown-repo';
20
+ }
21
+
22
+ export function getRepoSyncContext(baseDir) {
23
+ const repoRoot = runGit(['rev-parse', '--show-toplevel'], baseDir) || baseDir;
24
+ const originUrl = runGit(['config', '--get', 'remote.origin.url'], repoRoot);
25
+ const peer = runGit(['config', '--get', 'teleportation.transcriptSyncPeer'], repoRoot) || null;
26
+ const machineId = (hostname() || 'unknown-machine').split('.')[0];
27
+
28
+ const repoIdentitySource = originUrl || repoRoot;
29
+ const repoHash = createHash('sha256').update(repoIdentitySource).digest('hex').slice(0, 12);
30
+ const repoName = safeSlug((originUrl.split('/').pop() || '').replace(/\.git$/, '') || repoRoot.split('/').pop());
31
+ const repoKey = `${repoName}-${repoHash}`;
32
+
33
+ const mirrorDir = join(homedir(), '.teleportation', 'transcript-mirror', 'repos', repoKey);
34
+ const peerMirrorDir = `~/.teleportation/transcript-mirror/repos/${repoKey}`;
35
+
36
+ return {
37
+ repoRoot,
38
+ repoKey,
39
+ originUrl: originUrl || null,
40
+ machineId,
41
+ peer,
42
+ mirrorDir,
43
+ peerMirrorDir
44
+ };
45
+ }