@turns/dev 0.0.3 → 0.0.4

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/README.md CHANGED
@@ -44,6 +44,7 @@ invocations and daemon starts. On Linux the other persistent files are:
44
44
 
45
45
  - `~/.config/turns/machine-id`
46
46
  - `~/.local/state/turns/native-upload-state.json`
47
+ - `~/.local/state/turns/letta-sessions/*.json` (Letta conversation-to-project associations)
47
48
  - `~/.config/systemd/user/turns.service`
48
49
 
49
50
  The credential file contains a seven-day access token and a signed refresh
@@ -2,12 +2,14 @@
2
2
  import { spawn } from 'node:child_process';
3
3
  import fs from 'node:fs';
4
4
  import net from 'node:net';
5
+ import os from 'node:os';
5
6
  import path from 'node:path';
6
7
  import readline from 'node:readline';
7
8
  import { DatabaseSync } from 'node:sqlite';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { WebSocket } from 'ws';
10
- import { ACP_PROFILES, PROCESS_HARNESSES, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
11
+ import { ACP_PROFILES, lettaSessionIdentity, PROCESS_HARNESSES, parseLettaSessionIdentity, processEventSessionId, processInvocation, RPC_PROFILES, resolveHarnessCommand, rpcFrameSessionIdentity, rpcUiRequestIsInformational, } from "./harness-registry.js";
12
+ import { recordLettaSessionContext } from "./letta-session-context.js";
11
13
  const socketIndex = process.argv.indexOf('--socket');
12
14
  const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
13
15
  if (!socketPath)
@@ -333,6 +335,165 @@ class ClaudeTransport extends LineTransport {
333
335
  }
334
336
  }
335
337
  }
338
+ class LettaTransport extends LineTransport {
339
+ conversationId;
340
+ agentId;
341
+ permissionMode;
342
+ turnWaiter;
343
+ control;
344
+ initWaiter;
345
+ constructor(anchor, createSession) {
346
+ const existing = parseLettaSessionIdentity(anchor.agentSessionId);
347
+ const nativePermissionMode = anchor.permissionMode === 'bypassPermissions'
348
+ ? 'unrestricted'
349
+ : anchor.permissionMode === 'acceptEdits'
350
+ ? 'acceptEdits'
351
+ : 'standard';
352
+ const args = [
353
+ '-p',
354
+ '--input-format',
355
+ 'stream-json',
356
+ '--output-format',
357
+ 'stream-json',
358
+ '--include-partial-messages',
359
+ '--permission-mode',
360
+ nativePermissionMode,
361
+ '--reflection-trigger',
362
+ 'off',
363
+ ...(anchor.model && anchor.model !== 'auto' ? ['--model', anchor.model] : []),
364
+ ...(createSession ? ['--new'] : ['--conversation', existing.conversationId]),
365
+ ];
366
+ super(anchor, 'letta', resolveHarnessCommand('letta', { command: 'letta' }), args, anchor.cwd);
367
+ this.conversationId = createSession ? '' : existing.conversationId;
368
+ this.agentId = createSession ? '' : existing.agentId;
369
+ this.permissionMode = anchor.permissionMode;
370
+ this.turnWaiter = null;
371
+ this.control = new Map();
372
+ this.initWaiter = deferred();
373
+ }
374
+ onFrame(frame) {
375
+ if (!frame || typeof frame !== 'object')
376
+ return;
377
+ if (frame.type === 'system' && frame.subtype === 'init') {
378
+ this.agentId = String(frame.agent_id || frame.session_id || '');
379
+ this.conversationId = String(frame.conversation_id || '');
380
+ if (this.conversationId) {
381
+ const identity = this.agentId ? lettaSessionIdentity(this.agentId, this.conversationId) : this.conversationId;
382
+ const transcriptPath = this.agentId
383
+ ? path.join(os.homedir(), '.letta', 'transcripts', this.agentId, this.conversationId, 'transcript.jsonl')
384
+ : '';
385
+ if (this.agentId && transcriptPath) {
386
+ recordLettaSessionContext({
387
+ agentId: this.agentId,
388
+ conversationId: this.conversationId,
389
+ workingDirectory: this.anchor.cwd,
390
+ transcriptPath,
391
+ });
392
+ }
393
+ if (identity !== this.anchor.agentSessionId ||
394
+ (transcriptPath && path.resolve(transcriptPath) !== this.anchor.transcriptPath)) {
395
+ this.anchor.updateIdentity(identity, transcriptPath);
396
+ }
397
+ }
398
+ this.initWaiter.resolve(frame);
399
+ return;
400
+ }
401
+ if (frame.type === 'result' && this.turnWaiter) {
402
+ const waiter = this.turnWaiter;
403
+ this.turnWaiter = null;
404
+ frame.subtype === 'error' || frame.stop_reason === 'error'
405
+ ? waiter.reject(new Error(frame.result || 'Letta turn failed.'))
406
+ : waiter.resolve(frame);
407
+ return;
408
+ }
409
+ if (frame.type === 'control_response') {
410
+ const requestId = frame.response?.request_id;
411
+ const waiter = this.control.get(requestId);
412
+ if (!waiter)
413
+ return;
414
+ this.control.delete(requestId);
415
+ frame.response?.subtype === 'error'
416
+ ? waiter.reject(new Error(frame.response.error || 'Letta control request failed.'))
417
+ : waiter.resolve(frame.response?.response ?? frame.response);
418
+ return;
419
+ }
420
+ if (frame.type === 'control_request')
421
+ void this.answerReverse(frame);
422
+ }
423
+ onClose(error) {
424
+ this.initWaiter.reject(error);
425
+ if (this.turnWaiter) {
426
+ this.turnWaiter.reject(error);
427
+ this.turnWaiter = null;
428
+ }
429
+ for (const waiter of this.control.values())
430
+ waiter.reject(error);
431
+ this.control.clear();
432
+ }
433
+ async open(timeoutMs = 90_000) {
434
+ const timer = setTimeout(() => this.initWaiter.reject(new Error('Letta init timed out.')), timeoutMs);
435
+ timer.unref?.();
436
+ try {
437
+ await this.initWaiter.promise;
438
+ }
439
+ finally {
440
+ clearTimeout(timer);
441
+ }
442
+ }
443
+ controlRequest(subtype, extra = {}, timeoutMs = 30_000) {
444
+ const requestId = `turns_letta_${subtype}_${crypto.randomUUID()}`;
445
+ const waiter = deferred();
446
+ this.control.set(requestId, waiter);
447
+ this.emit({ type: 'control_request', request_id: requestId, request: { subtype, ...extra } });
448
+ const timer = setTimeout(() => {
449
+ if (this.control.delete(requestId))
450
+ waiter.reject(new Error(`Letta control request timed out: ${subtype}`));
451
+ }, timeoutMs);
452
+ timer.unref?.();
453
+ return waiter.promise.finally(() => clearTimeout(timer));
454
+ }
455
+ async answerReverse(frame) {
456
+ let response;
457
+ try {
458
+ response = await this.anchor.askInteraction(frame);
459
+ }
460
+ catch {
461
+ return;
462
+ }
463
+ this.emit({ type: 'control_response', response: { subtype: 'success', request_id: frame.request_id, response } });
464
+ }
465
+ prompt(text, timeoutMs) {
466
+ if (this.turnWaiter)
467
+ throw new Error('Letta already has an active turn.');
468
+ const waiter = deferred();
469
+ this.turnWaiter = waiter;
470
+ this.emit({ type: 'user', message: { role: 'user', content: text } });
471
+ const timer = setTimeout(() => {
472
+ if (this.turnWaiter === waiter)
473
+ this.turnWaiter = null;
474
+ waiter.reject(new Error('Letta turn timed out.'));
475
+ }, timeoutMs);
476
+ timer.unref?.();
477
+ return waiter.promise.finally(() => clearTimeout(timer));
478
+ }
479
+ async interrupt() {
480
+ try {
481
+ return await this.controlRequest('interrupt');
482
+ }
483
+ catch (error) {
484
+ try {
485
+ process.kill(-this.child.pid, 'SIGINT');
486
+ }
487
+ catch {
488
+ try {
489
+ this.child.kill('SIGINT');
490
+ }
491
+ catch { }
492
+ }
493
+ throw error;
494
+ }
495
+ }
496
+ }
336
497
  class CodexAppServerTransport extends LineTransport {
337
498
  threadId;
338
499
  permissionMode;
@@ -1380,6 +1541,26 @@ class Anchor {
1380
1541
  stopReason: result?.method || 'turn/completed',
1381
1542
  };
1382
1543
  }
1544
+ if (this.source === 'letta') {
1545
+ if (!(this.transport instanceof LettaTransport) ||
1546
+ this.transport.child.exitCode !== null ||
1547
+ lettaSessionIdentity(this.transport.agentId, this.transport.conversationId) !== this.agentSessionId ||
1548
+ this.transport.permissionMode !== this.permissionMode) {
1549
+ this.transport?.stop?.();
1550
+ this.transport = new LettaTransport(this, createSession);
1551
+ await this.transport.open();
1552
+ }
1553
+ if (interruptedBeforePrompt())
1554
+ return interruptedBeforePrompt();
1555
+ const result = await this.transport.prompt(this.currentTurn.prompt, timeoutMs);
1556
+ return {
1557
+ exitCode: 0,
1558
+ signal: null,
1559
+ timedOut: false,
1560
+ error: null,
1561
+ stopReason: result?.stop_reason || result?.subtype || 'completed',
1562
+ };
1563
+ }
1383
1564
  if (PROCESS_HARNESSES.has(this.source)) {
1384
1565
  const invocation = processInvocation({
1385
1566
  harness: this.source,
@@ -159,7 +159,15 @@ export const ACP_PROFILES = {
159
159
  // These harnesses keep one bidirectional process attached to the anchor. They
160
160
  // are listed separately from process harnesses so queued turns cannot
161
161
  // accidentally fall back to one-shot CLI invocations.
162
- export const RESIDENT_HARNESSES = new Set(['claude', 'codex']);
162
+ export const RESIDENT_HARNESSES = new Set(['claude', 'codex', 'letta']);
163
+ /** Letta's agent + conversation pair identifies one exact transcript. */
164
+ export function lettaSessionIdentity(agentId, conversationId) {
165
+ return `letta:${String(agentId)}:${String(conversationId)}`;
166
+ }
167
+ export function parseLettaSessionIdentity(value) {
168
+ const match = /^letta:(agent-(?:local-)?[0-9a-f-]+):(.+)$/iu.exec(String(value || ''));
169
+ return match ? { agentId: match[1], conversationId: match[2] } : { agentId: '', conversationId: String(value || '') };
170
+ }
163
171
  export const RPC_PROFILES = {
164
172
  // Prime selects an existing session only at process launch. The transcript
165
173
  // path is authoritative (its header id can differ from its filename id), so
@@ -0,0 +1,70 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { applicationPaths } from "./auth.js";
5
+ import { lettaSessionIdentity } from "./harness-registry.js";
6
+ function contextDirectory(stateDirectory = applicationPaths().stateDirectory) {
7
+ return path.join(stateDirectory, 'letta-sessions');
8
+ }
9
+ function contextFile(identity, stateDirectory) {
10
+ const digest = crypto.createHash('sha256').update(identity).digest('hex');
11
+ return path.join(contextDirectory(stateDirectory), `${digest}.json`);
12
+ }
13
+ /** Letta transcripts identify an agent and conversation but do not persist the
14
+ * cwd. Keep Turns-owned launch context outside Letta's data directory so a
15
+ * later daemon scan can safely resume the conversation in the same project. */
16
+ export function recordLettaSessionContext(context, stateDirectory) {
17
+ if (!context.agentId ||
18
+ !context.conversationId ||
19
+ !path.isAbsolute(context.workingDirectory) ||
20
+ !path.isAbsolute(context.transcriptPath))
21
+ return;
22
+ const identity = lettaSessionIdentity(context.agentId, context.conversationId);
23
+ const directory = contextDirectory(stateDirectory);
24
+ const file = contextFile(identity, stateDirectory);
25
+ const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
26
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
27
+ try {
28
+ fs.writeFileSync(temporary, `${JSON.stringify({ identity, ...context })}\n`, { mode: 0o600 });
29
+ try {
30
+ fs.renameSync(temporary, file);
31
+ }
32
+ catch (error) {
33
+ if (process.platform !== 'win32')
34
+ throw error;
35
+ try {
36
+ fs.unlinkSync(file);
37
+ }
38
+ catch { }
39
+ fs.renameSync(temporary, file);
40
+ }
41
+ }
42
+ finally {
43
+ try {
44
+ fs.unlinkSync(temporary);
45
+ }
46
+ catch { }
47
+ }
48
+ }
49
+ export function readLettaSessionContext(identity, stateDirectory) {
50
+ try {
51
+ const value = JSON.parse(fs.readFileSync(contextFile(identity, stateDirectory), 'utf8'));
52
+ if (value?.identity !== identity ||
53
+ !value.agentId ||
54
+ !value.conversationId ||
55
+ !path.isAbsolute(value.workingDirectory || '') ||
56
+ !path.isAbsolute(value.transcriptPath || ''))
57
+ return null;
58
+ if (lettaSessionIdentity(value.agentId, value.conversationId) !== identity)
59
+ return null;
60
+ return {
61
+ agentId: String(value.agentId),
62
+ conversationId: String(value.conversationId),
63
+ workingDirectory: String(value.workingDirectory),
64
+ transcriptPath: String(value.transcriptPath),
65
+ };
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
@@ -4,6 +4,8 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { DatabaseSync } from 'node:sqlite';
6
6
  import { readDeepseekTrajectoryHeader } from "./deepseek-trajectories.js";
7
+ import { lettaSessionIdentity } from "./harness-registry.js";
8
+ import { readLettaSessionContext } from "./letta-session-context.js";
7
9
  const HOME = os.homedir();
8
10
  const PROJECTION_ROOT = path.join(HOME, '.local', 'state', 'turns-dev', 'session-projections');
9
11
  const DEEPSEEK_SESSIONS_ROOT = path.join(path.resolve(process.env.DSH_HOME || path.join(HOME, '.dsh')), 'sessions');
@@ -91,6 +93,10 @@ const EXTRA_FILE_STORES = {
91
93
  root: path.join(HOME, '.fx', 'sessions'),
92
94
  include: (file) => path.basename(file) === 'events.jsonl',
93
95
  },
96
+ letta: {
97
+ root: path.join(HOME, '.letta', 'transcripts'),
98
+ include: (file) => path.basename(file) === 'transcript.jsonl',
99
+ },
94
100
  };
95
101
  function safeStat(file) {
96
102
  try {
@@ -253,6 +259,7 @@ function nativeSessionIdFromPath(harness, sourcePath) {
253
259
  cline: /\/([^/]+)\.messages\.json$/iu,
254
260
  poolside: /trajectory-(?:standalone_)?([0-9a-f-]{20,})\.ndjson$/iu,
255
261
  fx: /\/sessions\/([^/]+)\/events\.jsonl$/iu,
262
+ letta: /\/transcripts\/agent-(?:local-)?[0-9a-f-]+\/([^/]+)\/transcript\.jsonl$/iu,
256
263
  };
257
264
  return patterns[harness]?.exec(normalized)?.[1] || '';
258
265
  }
@@ -1097,6 +1104,13 @@ export function nativeDescriptorWorkingDirectory(descriptor) {
1097
1104
  cursor: cursorMetadataWorkingDirectory,
1098
1105
  gemini: geminiMetadataWorkingDirectory,
1099
1106
  kimi: kimiMetadataWorkingDirectory,
1107
+ letta: (candidate) => {
1108
+ const normalized = String(candidate.sourcePath || candidate.file || '').replaceAll('\\', '/');
1109
+ const match = /\/transcripts\/(agent-(?:local-)?[0-9a-f-]+)\/([^/]+)\/transcript\.jsonl$/iu.exec(normalized);
1110
+ if (!match)
1111
+ return '';
1112
+ return readLettaSessionContext(lettaSessionIdentity(match[1], match[2]))?.workingDirectory || '';
1113
+ },
1100
1114
  fx: (candidate) => absolutePathCandidate([
1101
1115
  sidecarJson(candidate, 'session.json')?.workspace_root,
1102
1116
  sidecarJson(candidate, 'session.json')?.origin_workspace_root,
@@ -1176,6 +1190,12 @@ export async function nativeDescriptorAgentSessionId(descriptor) {
1176
1190
  // into work whose lifecycle and ownership belong to its parent agent.
1177
1191
  if (subagent)
1178
1192
  return '';
1193
+ if (descriptor?.harness === 'letta') {
1194
+ const normalized = String(descriptor?.sourcePath || descriptor?.file || '').replaceAll('\\', '/');
1195
+ const match = /\/transcripts\/(agent-(?:local-)?[0-9a-f-]+)\/([^/]+)\/transcript\.jsonl$/iu.exec(normalized);
1196
+ if (match)
1197
+ return lettaSessionIdentity(match[1], match[2]);
1198
+ }
1179
1199
  const structural = descriptorNativeSessionId(descriptor);
1180
1200
  if (structural) {
1181
1201
  // Kimi persists its ACP key as `session_<uuid>`. The path parser used to
@@ -1309,6 +1329,7 @@ const HARNESS_PROCESS_NAMES = {
1309
1329
  hermes: ['hermes'],
1310
1330
  kilo: ['kilo'],
1311
1331
  kimi: ['kimi', 'kimi-code'],
1332
+ letta: ['letta'],
1312
1333
  muse: ['muse'],
1313
1334
  omp: ['omp'],
1314
1335
  openclaw: ['openclaw'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",