@100xprompt/brain 0.0.0-stage → 0.1.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.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- package/src/sqlite.ts +48 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { extname, join } from "node:path"
|
|
4
|
+
import { toolLabel } from "../../brief.ts"
|
|
5
|
+
import { exists, isDir, readJson, versionOf } from "../../fsutil.ts"
|
|
6
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
7
|
+
import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
|
|
8
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
9
|
+
import { lettaReader } from "./reader.ts"
|
|
10
|
+
import {
|
|
11
|
+
LABEL,
|
|
12
|
+
MANIFEST,
|
|
13
|
+
SESSION_HEADER_VERSION,
|
|
14
|
+
TESTED_VERSIONS,
|
|
15
|
+
TOOL,
|
|
16
|
+
continuedNote,
|
|
17
|
+
conversationDirs,
|
|
18
|
+
conversationKey,
|
|
19
|
+
conversationsDir,
|
|
20
|
+
encodeSegment,
|
|
21
|
+
isLocalAgentId,
|
|
22
|
+
localAgents,
|
|
23
|
+
numericSuffix,
|
|
24
|
+
storageDir,
|
|
25
|
+
} from "./records.ts"
|
|
26
|
+
|
|
27
|
+
// Continues a portable session as a new conversation of one of the person's local Letta agents, written
|
|
28
|
+
// the way LocalStore writes a forked conversation: a folder under lc-local-backend/conversations with
|
|
29
|
+
// conversation.json (the title as its summary, every message in context), manifest.json and messages.jsonl
|
|
30
|
+
// (the session header, then one entry per message on a parentId chain). `letta --agent <id> --conversation
|
|
31
|
+
// <id>` opens it with the earlier turns on screen, and /resume lists it under that agent. Letta's server
|
|
32
|
+
// ("cloud") agents keep their conversations on the server, which only takes new messages by running the
|
|
33
|
+
// agent, so they are not a target here.
|
|
34
|
+
//
|
|
35
|
+
// Messages are pi-ai messages, as Letta stores them: one assistant message per model response (its readable
|
|
36
|
+
// reasoning as thinking without a signature, its text, its toolCall blocks), then one toolResult message per
|
|
37
|
+
// call, right after it, since Letta drops a result that does not follow its call. A call without a result is
|
|
38
|
+
// closed as an error. Assistant messages name the source tool as their provider and "brain-session" as their
|
|
39
|
+
// api, so pi-ai treats them as another model's turns (their reasoning goes to the model as text, their call
|
|
40
|
+
// ids are normalised). A compaction is Letta's compaction entry: the summary becomes a user message packed
|
|
41
|
+
// as a system alert, and only it and what follows stay in context. The note saying where the conversation
|
|
42
|
+
// came from is a <system-reminder> in the first user message in context, which Letta keeps off screen.
|
|
43
|
+
//
|
|
44
|
+
// The agent is the one Letta last used for the local store (the project's settings, then the global ones),
|
|
45
|
+
// else the newest visible local agent. The agent's memory stays its own; only the conversation is new.
|
|
46
|
+
|
|
47
|
+
const API = "brain-session"
|
|
48
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
|
49
|
+
const IMAGE_LIMIT = 5 * 1024 * 1024
|
|
50
|
+
const ZERO_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
|
|
51
|
+
|
|
52
|
+
type Json = Record<string, any>
|
|
53
|
+
type Call = Extract<Turn, { kind: "tool_call" }>
|
|
54
|
+
type Result = Extract<Turn, { kind: "tool_result" }>
|
|
55
|
+
|
|
56
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
57
|
+
|
|
58
|
+
function realCwd(cwd: string): string {
|
|
59
|
+
try {
|
|
60
|
+
return realpathSync(cwd)
|
|
61
|
+
} catch {
|
|
62
|
+
return cwd
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Tool names and call ids the providers Letta talks to accept (Anthropic's ^[a-zA-Z0-9_-]{1,64}$). */
|
|
67
|
+
const toolName = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "tool"
|
|
68
|
+
const safeId = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "call"
|
|
69
|
+
|
|
70
|
+
/** Tool input as an object, as pi-ai's toolCall.arguments must be; a raw string or array is wrapped. */
|
|
71
|
+
const objectInput = (input: unknown): Json => (input && typeof input === "object" && !Array.isArray(input) ? (input as Json) : { input: input ?? null })
|
|
72
|
+
|
|
73
|
+
/** Letta's packageLocalSummaryMessage for an "all" compaction. */
|
|
74
|
+
const packedSummary = (summary: string, time: string) =>
|
|
75
|
+
JSON.stringify({
|
|
76
|
+
type: "system_alert",
|
|
77
|
+
message: `Note: prior messages have been hidden from view due to conversation memory constraints.\nThe following is a summary of the previous messages:\n ${summary}`,
|
|
78
|
+
time,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** The local agent to file the conversation under: the last one Letta used here, else the newest visible one. */
|
|
82
|
+
export function pickAgent(home: string, cwd: string): string | undefined {
|
|
83
|
+
const agents = localAgents(home)
|
|
84
|
+
const known = new Set(agents.map((a) => a.id))
|
|
85
|
+
const last = (settings: Json | undefined): string | undefined => {
|
|
86
|
+
if (!settings) return undefined
|
|
87
|
+
for (const [server, s] of Object.entries((settings.sessionsByServer ?? {}) as Record<string, Json>))
|
|
88
|
+
if (server.startsWith("local:") && known.has(s?.agentId)) return s.agentId
|
|
89
|
+
for (const id of [settings.lastSession?.agentId, settings.lastAgent]) if (isLocalAgentId(id) && known.has(id)) return id
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
return (
|
|
93
|
+
last(readJson<Json>(join(cwd, ".letta", "settings.local.json"))) ??
|
|
94
|
+
last(readJson<Json>(join(home, ".letta", "settings.json"))) ??
|
|
95
|
+
agents.find((a) => !a.hidden)?.id
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ConversationPlan {
|
|
100
|
+
/** conversation.json */
|
|
101
|
+
record: Json
|
|
102
|
+
/** messages.jsonl rows: the session header, then message and compaction entries. */
|
|
103
|
+
rows: Json[]
|
|
104
|
+
title: string
|
|
105
|
+
lost: string[]
|
|
106
|
+
/** What a read-back must find. */
|
|
107
|
+
expect: { users: number; assistants: number; calls: number }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The files of new conversation `conversationId` of `agentId` continuing `session`. Pure apart from reading attached images. */
|
|
111
|
+
export function planConversation(
|
|
112
|
+
session: Session,
|
|
113
|
+
opts: { agentId: string; conversationId: string; firstMessageSeq: number; now: Date; cwd: string },
|
|
114
|
+
): ConversationPlan {
|
|
115
|
+
const drops = new Map<string, number>()
|
|
116
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
117
|
+
const label = toolLabel(session.header.tool)
|
|
118
|
+
const expect = { users: 0, assistants: 0, calls: 0 }
|
|
119
|
+
const nowIso = opts.now.toISOString()
|
|
120
|
+
|
|
121
|
+
let seq = opts.firstMessageSeq
|
|
122
|
+
const messageId = () => `ui-msg-${seq++}`
|
|
123
|
+
// Timestamps never go backwards, and fall back to just before now for turns that carry none.
|
|
124
|
+
const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
125
|
+
let clock = (known ?? opts.now.getTime()) - 1
|
|
126
|
+
const stamp = (at?: string) => {
|
|
127
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
128
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 1
|
|
129
|
+
return clock
|
|
130
|
+
}
|
|
131
|
+
const meta = (ms: number) => ({ created_at: new Date(ms).toISOString(), updated_at: new Date(ms).toISOString(), agent_id: opts.agentId, conversation_id: opts.conversationId })
|
|
132
|
+
|
|
133
|
+
// Calls are answered right after the message holding them; results are looked up by call id.
|
|
134
|
+
const results = new Map<string, Result>()
|
|
135
|
+
for (const t of session.turns) if (t.kind === "tool_result" && !results.has(t.callId)) results.set(t.callId, t)
|
|
136
|
+
const ids = new Map<string, string>()
|
|
137
|
+
const used = new Set<string>()
|
|
138
|
+
let remapped = 0
|
|
139
|
+
const idFor = (id: string) => {
|
|
140
|
+
let v = ids.get(id)
|
|
141
|
+
if (v) return v
|
|
142
|
+
v = safeId(id)
|
|
143
|
+
for (let n = 2; used.has(v); n++) v = `${safeId(id)}_${n}`
|
|
144
|
+
ids.set(id, v)
|
|
145
|
+
used.add(v)
|
|
146
|
+
if (v !== id) remapped++
|
|
147
|
+
return v
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Items in order: messages, and compaction markers (the summary message they carry).
|
|
151
|
+
type Item = { kind: "message"; message: Json } | { kind: "compaction"; summary: string; message: Json }
|
|
152
|
+
const items: Item[] = []
|
|
153
|
+
let cur: { msg: Json; calls: Call[] } | undefined
|
|
154
|
+
const flush = () => {
|
|
155
|
+
if (!cur) return
|
|
156
|
+
const { msg, calls } = cur
|
|
157
|
+
cur = undefined
|
|
158
|
+
if (!msg.content.length) return
|
|
159
|
+
msg.stopReason = calls.length ? "toolUse" : "stop"
|
|
160
|
+
items.push({ kind: "message", message: msg })
|
|
161
|
+
for (const call of calls) {
|
|
162
|
+
const r = results.get(call.id)
|
|
163
|
+
const ts = stamp(r?.at)
|
|
164
|
+
const output = r ? (r.fullPath ? `${r.output}\n\n[Full output: ${r.fullPath}]` : r.output) : "No result was recorded for this call; the session moved on before it finished."
|
|
165
|
+
if (!r) drop("tool calls without a result (closed as errors)")
|
|
166
|
+
items.push({
|
|
167
|
+
kind: "message",
|
|
168
|
+
message: {
|
|
169
|
+
id: messageId(),
|
|
170
|
+
role: "toolResult",
|
|
171
|
+
toolCallId: idFor(call.id),
|
|
172
|
+
toolName: toolName(call.name),
|
|
173
|
+
content: [{ type: "text", text: output.trim() ? output : "(no output)" }],
|
|
174
|
+
isError: r ? !r.ok : true,
|
|
175
|
+
timestamp: ts,
|
|
176
|
+
metadata: meta(ts),
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const assistant = (at: string | undefined, model: string | undefined) => {
|
|
182
|
+
if (!cur) {
|
|
183
|
+
const ts = stamp(at)
|
|
184
|
+
cur = {
|
|
185
|
+
msg: { id: messageId(), role: "assistant", content: [], api: API, provider: session.header.tool, model: model ?? session.header.models.at(-1) ?? "unknown", usage: ZERO_USAGE, stopReason: "stop", timestamp: ts, metadata: meta(ts) },
|
|
186
|
+
calls: [],
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return cur
|
|
190
|
+
}
|
|
191
|
+
const hasCalls = () => !!cur?.calls.length
|
|
192
|
+
const seen = new Set<string>()
|
|
193
|
+
|
|
194
|
+
for (const t of session.turns) {
|
|
195
|
+
switch (t.kind) {
|
|
196
|
+
case "user": {
|
|
197
|
+
flush()
|
|
198
|
+
const content: Json[] = []
|
|
199
|
+
let text = t.text
|
|
200
|
+
for (const a of t.attachments ?? []) {
|
|
201
|
+
if (!a.path) continue // bytes the reader could not keep; it already counted them
|
|
202
|
+
if (a.kind === "file") {
|
|
203
|
+
text += `\n\n[Attached file: ${a.path}]`
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
|
|
207
|
+
let size = Number.POSITIVE_INFINITY
|
|
208
|
+
try {
|
|
209
|
+
size = statSync(a.path).size
|
|
210
|
+
} catch {}
|
|
211
|
+
if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT) content.push({ type: "image", data: readFileSync(a.path).toString("base64"), mimeType: mime })
|
|
212
|
+
else drop("images Letta cannot hold")
|
|
213
|
+
}
|
|
214
|
+
if (!text.trim() && !content.length) break
|
|
215
|
+
if (text.trim()) content.unshift({ type: "text", text })
|
|
216
|
+
const ts = stamp(t.at)
|
|
217
|
+
items.push({ kind: "message", message: { id: messageId(), role: "user", content, timestamp: ts, metadata: meta(ts) } })
|
|
218
|
+
if (text.trim()) expect.users++
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
case "assistant": {
|
|
222
|
+
// Text after a call is a new response: it was written after the call's result came back.
|
|
223
|
+
if (hasCalls()) flush()
|
|
224
|
+
if (!t.text.trim()) break
|
|
225
|
+
assistant(t.at, t.model).msg.content.push({ type: "text", text: t.text })
|
|
226
|
+
expect.assistants++
|
|
227
|
+
break
|
|
228
|
+
}
|
|
229
|
+
case "reasoning":
|
|
230
|
+
if (hasCalls()) flush()
|
|
231
|
+
if (t.text.trim()) assistant(t.at, undefined).msg.content.push({ type: "thinking", thinking: t.text })
|
|
232
|
+
break
|
|
233
|
+
case "tool_call": {
|
|
234
|
+
if (seen.has(t.id)) {
|
|
235
|
+
drop("repeated tool call ids")
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
seen.add(t.id)
|
|
239
|
+
const name = toolName(t.name)
|
|
240
|
+
if (name !== t.name) drop("tool names changed to fit Letta")
|
|
241
|
+
const a = assistant(t.at, undefined)
|
|
242
|
+
a.msg.content.push({ type: "toolCall", id: idFor(t.id), name, arguments: objectInput(t.input) })
|
|
243
|
+
a.calls.push(t)
|
|
244
|
+
expect.calls++
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
case "tool_result":
|
|
248
|
+
if (!seen.has(t.callId)) drop("tool results without a call")
|
|
249
|
+
break
|
|
250
|
+
case "compaction": {
|
|
251
|
+
flush()
|
|
252
|
+
const ts = stamp(t.at)
|
|
253
|
+
const summary = `[Summary of the earlier conversation in ${label}, which replaced the turns before it]\n\n${t.summary}`
|
|
254
|
+
items.push({
|
|
255
|
+
kind: "compaction",
|
|
256
|
+
summary,
|
|
257
|
+
message: { id: messageId(), role: "user", content: [{ type: "text", text: packedSummary(summary, new Date(ts).toISOString()) }], timestamp: ts, metadata: { ...meta(ts), compaction: { summary } } },
|
|
258
|
+
})
|
|
259
|
+
break
|
|
260
|
+
}
|
|
261
|
+
case "file_edit":
|
|
262
|
+
if (!t.callId || !seen.has(t.callId)) drop("file edits outside a tool call")
|
|
263
|
+
break
|
|
264
|
+
case "subagent":
|
|
265
|
+
drop("subagent sessions (their results stay in the parent's tool results)")
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
flush()
|
|
270
|
+
for (const t of session.turns) if (t.kind === "tool_result" && results.get(t.callId) !== t) drop("repeated tool results")
|
|
271
|
+
if (!items.some((i) => i.kind === "message")) throw new Error("nothing Letta can show: the session has no messages or tool calls")
|
|
272
|
+
|
|
273
|
+
// Context starts at the last compaction; the note goes in the first user message there (or opens it).
|
|
274
|
+
const last = items.map((i) => i.kind).lastIndexOf("compaction")
|
|
275
|
+
const note = { type: "text", text: continuedNote(label, session.header.id, nowIso.slice(0, 10)) }
|
|
276
|
+
const firstUser = items.findIndex((i, k) => k > last && i.kind === "message" && i.message.role === "user")
|
|
277
|
+
if (firstUser >= 0) (items[firstUser] as { message: Json }).message.content.push(note)
|
|
278
|
+
else {
|
|
279
|
+
const ts = stamp(undefined)
|
|
280
|
+
items.splice(last + 1, 0, { kind: "message", message: { id: messageId(), role: "user", content: [note], timestamp: ts, metadata: meta(ts) } })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Letta shows each part of a message 1 ms after the one before it and sorts by time, so every message
|
|
284
|
+
// starts after the last part of the one before it.
|
|
285
|
+
let next = 0
|
|
286
|
+
for (const { message: m } of items) {
|
|
287
|
+
m.timestamp = Math.max(m.timestamp, next)
|
|
288
|
+
m.metadata.created_at = m.metadata.updated_at = new Date(m.timestamp).toISOString()
|
|
289
|
+
next = m.timestamp + Math.max(1, m.role === "assistant" ? m.content.length : 1)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The transcript: header, then entries on one parentId chain with 8-hex ids, as LocalStore appends them.
|
|
293
|
+
const rows: Json[] = [{ type: "session", version: SESSION_HEADER_VERSION, id: opts.conversationId, timestamp: nowIso, cwd: opts.cwd }]
|
|
294
|
+
const usedEntries = new Set<string>()
|
|
295
|
+
const entryId = () => {
|
|
296
|
+
let v = randomUUID().slice(0, 8)
|
|
297
|
+
while (usedEntries.has(v)) v = randomUUID().slice(0, 8)
|
|
298
|
+
usedEntries.add(v)
|
|
299
|
+
return v
|
|
300
|
+
}
|
|
301
|
+
let parentId: string | null = null
|
|
302
|
+
for (const item of items) {
|
|
303
|
+
const id = entryId()
|
|
304
|
+
const timestamp = new Date(item.message.timestamp).toISOString()
|
|
305
|
+
// A compaction keeps nothing from before it, so its firstKeptEntryId is null.
|
|
306
|
+
rows.push(
|
|
307
|
+
item.kind === "message"
|
|
308
|
+
? { type: "message", id, parentId, timestamp, message: item.message }
|
|
309
|
+
: { type: "compaction", id, parentId, timestamp, summary: item.summary, firstKeptEntryId: null, tokensBefore: 0, message: item.message },
|
|
310
|
+
)
|
|
311
|
+
parentId = id
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const inContext = items.slice(last >= 0 ? last : 0).map((i) => i.message.id as string)
|
|
315
|
+
const lastAt = new Date(items.at(-1)!.message.timestamp).toISOString()
|
|
316
|
+
const firstText = firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60)
|
|
317
|
+
const title = `${session.header.title ?? firstText ?? "Session"} (from ${label})`
|
|
318
|
+
const record: Json = {
|
|
319
|
+
id: opts.conversationId,
|
|
320
|
+
agent_id: opts.agentId,
|
|
321
|
+
archived: false,
|
|
322
|
+
archived_at: null,
|
|
323
|
+
created_at: nowIso,
|
|
324
|
+
updated_at: nowIso,
|
|
325
|
+
last_message_at: lastAt,
|
|
326
|
+
summary: title,
|
|
327
|
+
in_context_message_ids: inContext,
|
|
328
|
+
}
|
|
329
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
330
|
+
if (remapped) lost.push(`tool call ids, changed to fit Letta (${remapped})`)
|
|
331
|
+
return { record, rows, title, lost, expect }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The next free local-conv-<n> and ui-msg-<n>, past every conversation already on disk. */
|
|
335
|
+
export function nextIds(home: string): { conversationId: string; firstMessageSeq: number } {
|
|
336
|
+
let conv = 0
|
|
337
|
+
let msg = 0
|
|
338
|
+
for (const { key, record } of conversationDirs(home)) {
|
|
339
|
+
if (key.startsWith("conversation:")) conv = Math.max(conv, numericSuffix(key.slice("conversation:".length), "local-conv-"))
|
|
340
|
+
for (const id of Array.isArray(record?.in_context_message_ids) ? record.in_context_message_ids : []) msg = Math.max(msg, numericSuffix(id, "ui-msg-"))
|
|
341
|
+
}
|
|
342
|
+
for (let n = conv + 1; ; n++) {
|
|
343
|
+
const conversationId = `local-conv-${n}`
|
|
344
|
+
if (!exists(join(conversationsDir(home), encodeSegment(conversationKey(conversationId, ""))))) return { conversationId, firstMessageSeq: msg + 1 }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const semver = (v: string | undefined) => v?.match(/(\d+\.\d+\.\d+)/)?.[1]
|
|
349
|
+
|
|
350
|
+
/** The Letta writer; `version` is for tests, which must not depend on what is installed. */
|
|
351
|
+
export function lettaWriter(opts: { version?: string } = {}): Writer {
|
|
352
|
+
const installed = () => opts.version ?? semver(versionOf("letta"))
|
|
353
|
+
return {
|
|
354
|
+
tool: TOOL,
|
|
355
|
+
label: LABEL,
|
|
356
|
+
way: "native",
|
|
357
|
+
|
|
358
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
359
|
+
const v = installed()
|
|
360
|
+
if (!v) return { ok: false, why: "Letta Code is not installed (no `letta` on the PATH)" }
|
|
361
|
+
if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Letta Code ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
|
|
362
|
+
if (!isDir(storageDir(w.home)))
|
|
363
|
+
return { ok: false, why: "Letta has no local store here: its agents live on the Letta server, which only adds messages by running the agent. Create a local agent with `letta --backend local` first" }
|
|
364
|
+
if (!pickAgent(w.home, w.cwd)) return { ok: false, why: "Letta's local store has no agent to file the conversation under; create one with `letta --backend local` first" }
|
|
365
|
+
if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder` }
|
|
366
|
+
return { ok: true }
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
370
|
+
const now = new Date()
|
|
371
|
+
const cwd = realCwd(w.cwd)
|
|
372
|
+
const agentId = pickAgent(w.home, cwd)
|
|
373
|
+
if (!agentId) throw new Error("Letta's local store has no agent to file the conversation under; create one with `letta --backend local` first")
|
|
374
|
+
const { conversationId, firstMessageSeq } = nextIds(w.home)
|
|
375
|
+
const plan = planConversation(session, { agentId, conversationId, firstMessageSeq, now, cwd })
|
|
376
|
+
const dir = join(conversationsDir(w.home), encodeSegment(conversationKey(conversationId, agentId)))
|
|
377
|
+
const files = [join(dir, "conversation.json"), join(dir, "manifest.json"), join(dir, "messages.jsonl")]
|
|
378
|
+
if (!w.dryRun) {
|
|
379
|
+
mkdirSync(dir, { recursive: true })
|
|
380
|
+
writeFileSync(files[1]!, `${JSON.stringify({ ...MANIFEST, created_at: now.toISOString() }, null, 2)}\n`)
|
|
381
|
+
writeFileSync(files[2]!, `${plan.rows.map((r) => JSON.stringify(r)).join("\n")}\n`)
|
|
382
|
+
// The record last: Letta lists a conversation by its record, so it never sees a half-written one.
|
|
383
|
+
writeFileSync(files[0]!, `${JSON.stringify(plan.record, null, 2)}\n`)
|
|
384
|
+
let problem: string | undefined
|
|
385
|
+
try {
|
|
386
|
+
const back = await lettaReader.read({ tool: TOOL, sourceId: conversationKey(conversationId, agentId), path: dir }, w.home)
|
|
387
|
+
const n = countTurns(back)
|
|
388
|
+
const pairs = unpaired(back)
|
|
389
|
+
if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
|
|
390
|
+
problem = `users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}`
|
|
391
|
+
} catch (e) {
|
|
392
|
+
problem = e instanceof Error ? e.message : String(e)
|
|
393
|
+
}
|
|
394
|
+
if (problem) {
|
|
395
|
+
rmSync(dir, { recursive: true, force: true })
|
|
396
|
+
throw new Error(`the new Letta conversation did not read back as written (${problem}); it was removed`)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
tool: TOOL,
|
|
401
|
+
way: "native",
|
|
402
|
+
sessionId: conversationId,
|
|
403
|
+
written: files,
|
|
404
|
+
open: `cd ${shellQuote(cwd)} && letta --agent ${agentId} --conversation ${conversationId}`,
|
|
405
|
+
note: `Filed under the local agent ${agentId}; /resume lists it as "${plan.title}".`,
|
|
406
|
+
lost: plan.lost,
|
|
407
|
+
dryRun: !!w.dryRun,
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export const lettaNativeWriter = lettaWriter()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Reader, Writer } from "../types.ts"
|
|
2
|
+
import { mastracodeReader } from "./reader.ts"
|
|
3
|
+
import { mastracodeWriter } from "./writer.ts"
|
|
4
|
+
|
|
5
|
+
// Mastra Code (the mastracode CLI): reads every thread of its local mastra.db, and continues any session as a
|
|
6
|
+
// new native thread in that store (with MastraCode closed, after a backup). Ported from session-migrate (MIT).
|
|
7
|
+
export const readers: Reader[] = [mastracodeReader]
|
|
8
|
+
export const writers: Writer[] = [mastracodeWriter]
|