@100xprompt/brain 0.0.0-stage → 0.1.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 +48 -0
- package/README.md +172 -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 +326 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/connect.ts +845 -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,184 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { exists, gitInfo, ls, mtime } from "../../fsutil.ts"
|
|
4
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
5
|
+
import { clip, header, iso, lossCounter, noteModel } from "../../session/util.ts"
|
|
6
|
+
import { CONTINUED_NOTE } from "../claude-code/records.ts"
|
|
7
|
+
import { patchFiles } from "../codex/records.ts"
|
|
8
|
+
import type { Reader, SessionRef } from "../types.ts"
|
|
9
|
+
import { type Event, kindOf, parseEvents, personText, sessionsRoot } from "./records.ts"
|
|
10
|
+
|
|
11
|
+
// Reads Factory Droid sessions: ~/.factory/sessions/<project key>/<uuid>.jsonl. Messages are read in file
|
|
12
|
+
// order, as Droid loads them. Notices only the person saw (visibility "user_only", hook runs) and context
|
|
13
|
+
// only the model saw ("llm_only", e.g. reminders) are left out; the note a brain writer adds is recognised.
|
|
14
|
+
// Each LLM compaction becomes a compaction turn right after the message it summarised up to, with every
|
|
15
|
+
// earlier turn kept in front of it. Droid's provider-switch serialisations are skipped: they restate the
|
|
16
|
+
// messages that are still in the file.
|
|
17
|
+
|
|
18
|
+
const PEEK = 64 * 1024
|
|
19
|
+
const DEFAULT_TITLE = "New Session"
|
|
20
|
+
|
|
21
|
+
type Lost = ReturnType<typeof lossCounter>
|
|
22
|
+
|
|
23
|
+
/** The text of a content list, counting images it cannot keep. */
|
|
24
|
+
function textOf(content: unknown, lost?: Lost, what = "images"): string {
|
|
25
|
+
if (typeof content === "string") return content
|
|
26
|
+
if (!Array.isArray(content)) return ""
|
|
27
|
+
const parts: string[] = []
|
|
28
|
+
for (const b of content as Event[]) {
|
|
29
|
+
if (b?.type === "text" && typeof b.text === "string") parts.push(b.text)
|
|
30
|
+
else if (b?.type === "image") lost?.add(what)
|
|
31
|
+
else if (b?.type === "document") lost?.add("attached documents")
|
|
32
|
+
}
|
|
33
|
+
return parts.join("\n")
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Whether a message is part of the conversation the model and the person both saw. */
|
|
37
|
+
const shared = (m: Event) => m.visibility !== "user_only" && m.visibility !== "llm_only" && m.hiddenFromUserViews !== true && m.hookEventName === undefined
|
|
38
|
+
|
|
39
|
+
/** One Droid session file as the portable session. */
|
|
40
|
+
export function parseSession(text: string, path: string, home?: string): Session {
|
|
41
|
+
const events = parseEvents(text)
|
|
42
|
+
if (!events.length) throw new Error(`${path} is not a Factory Droid session (no session_start line)`)
|
|
43
|
+
const start = events.find((e) => e.type === "session_start")!
|
|
44
|
+
const id = String(start.id)
|
|
45
|
+
const cwd = typeof start.cwd === "string" && start.cwd ? start.cwd : undefined
|
|
46
|
+
const lost = lossCounter()
|
|
47
|
+
const turns: Turn[] = []
|
|
48
|
+
const firstAt = events.find((e) => e.type === "message" && iso(e.timestamp))?.timestamp
|
|
49
|
+
const h = header("factory-droid", id, { sourcePath: path, project: { ...(cwd ? { cwd, ...gitInfo(cwd, home) } : {}) }, ...(iso(firstAt) ? { startedAt: iso(firstAt) } : {}) })
|
|
50
|
+
|
|
51
|
+
const messages = events.filter((e) => e.type === "message" && e.message && typeof e.message === "object")
|
|
52
|
+
// LLM compactions by the message they summarised up to; one with no anchor summarises a previous session.
|
|
53
|
+
const after = new Map<number, Event[]>()
|
|
54
|
+
for (const c of events) {
|
|
55
|
+
if (c.type !== "compaction_state" || (c.summaryKind ?? "llm_summary") !== "llm_summary" || typeof c.summaryText !== "string") continue
|
|
56
|
+
let k = typeof c.anchorMessage?.id === "string" ? messages.findIndex((m) => m.id === c.anchorMessage.id) : -1
|
|
57
|
+
if (k < 0 && typeof c.anchorMessage?.index === "number" && c.anchorMessage.index < messages.length) k = c.anchorMessage.index
|
|
58
|
+
after.set(k, [...(after.get(k) ?? []), c])
|
|
59
|
+
}
|
|
60
|
+
const compaction = (c: Event) => {
|
|
61
|
+
if (c.summaryText.trim()) turns.push({ kind: "compaction", ...at(c.timestamp), summary: c.summaryText })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const calls = new Map<string, Extract<Turn, { kind: "tool_call" }>>()
|
|
65
|
+
let continued: string | undefined
|
|
66
|
+
let last: string | undefined
|
|
67
|
+
for (const c of after.get(-1) ?? []) compaction(c)
|
|
68
|
+
for (const [i, e] of messages.entries()) {
|
|
69
|
+
if (typeof e.timestamp === "string") last = e.timestamp
|
|
70
|
+
const m = e.message as Event
|
|
71
|
+
const stamp = at(e.timestamp)
|
|
72
|
+
if (m.visibility === "llm_only") {
|
|
73
|
+
const note = textOf(m.content).match(CONTINUED_NOTE)
|
|
74
|
+
if (note) continued = note[1]
|
|
75
|
+
} else if (shared(m)) {
|
|
76
|
+
if (m.role === "assistant") assistant(m, stamp)
|
|
77
|
+
else user(m, stamp)
|
|
78
|
+
}
|
|
79
|
+
for (const c of after.get(i) ?? []) compaction(c)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function assistant(m: Event, stamp: { at?: string }) {
|
|
83
|
+
const model = typeof m.modelId === "string" ? m.modelId : undefined
|
|
84
|
+
noteModel(h, model)
|
|
85
|
+
for (const b of Array.isArray(m.content) ? (m.content as Event[]) : [{ type: "text", text: String(m.content ?? "") }]) {
|
|
86
|
+
if (b?.type === "text" && typeof b.text === "string" && b.text.trim()) turns.push({ kind: "assistant", ...stamp, text: b.text, ...(model ? { model } : {}) })
|
|
87
|
+
else if (b?.type === "thinking") {
|
|
88
|
+
if (typeof b.thinking === "string" && b.thinking.trim()) turns.push({ kind: "reasoning", ...stamp, text: b.thinking })
|
|
89
|
+
else if (b.signature) lost.add("signed reasoning")
|
|
90
|
+
} else if (b?.type === "redacted_thinking") lost.add("redacted reasoning")
|
|
91
|
+
else if (b?.type === "tool_use" && typeof b.id === "string") {
|
|
92
|
+
// Droid renames MultiEdit to Edit when it loads a session.
|
|
93
|
+
const name = b.name === "MultiEdit" ? "Edit" : typeof b.name === "string" ? b.name : "tool"
|
|
94
|
+
const call: Extract<Turn, { kind: "tool_call" }> = { kind: "tool_call", ...stamp, id: b.id, name, toolKind: kindOf(name), input: b.input ?? {} }
|
|
95
|
+
calls.set(b.id, call)
|
|
96
|
+
turns.push(call)
|
|
97
|
+
} else if (b?.type === "image") lost.add("images")
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function user(m: Event, stamp: { at?: string }) {
|
|
102
|
+
const blocks = Array.isArray(m.content) ? (m.content as Event[]) : [{ type: "text", text: String(m.content ?? "") }]
|
|
103
|
+
for (const b of blocks) {
|
|
104
|
+
if (b?.type !== "tool_result" || typeof b.tool_use_id !== "string") continue
|
|
105
|
+
const full = clip(textOf(b.content, lost, "tool result images"))
|
|
106
|
+
const ok = b.is_error !== true
|
|
107
|
+
turns.push({ kind: "tool_result", ...stamp, callId: b.tool_use_id, ok, output: full.text, ...(full.truncated ? { truncated: true } : {}) })
|
|
108
|
+
const call = calls.get(b.tool_use_id)
|
|
109
|
+
const input = (call?.input ?? {}) as Event
|
|
110
|
+
if (!ok || !call) continue
|
|
111
|
+
if ((call.name === "Edit" || call.name === "Create") && typeof input.file_path === "string") turns.push({ kind: "file_edit", ...stamp, path: input.file_path, callId: call.id })
|
|
112
|
+
else if (call.name === "ApplyPatch" && typeof input.input === "string")
|
|
113
|
+
for (const f of patchFiles(input.input)) turns.push({ kind: "file_edit", ...stamp, path: f.path, patch: f.patch, callId: call.id })
|
|
114
|
+
}
|
|
115
|
+
// Text beside tool results is what the person typed while the tools ran; reminders are not theirs.
|
|
116
|
+
const text = textOf(blocks.filter((b) => b?.type !== "tool_result"), lost)
|
|
117
|
+
if (personText(text)) turns.push({ kind: "user", ...stamp, text: personText(text) })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const title = typeof start.title === "string" && start.title.trim() && start.title !== DEFAULT_TITLE ? start.title.trim() : undefined
|
|
121
|
+
h.title = title ?? (turns.find((t) => t.kind === "user") as Extract<Turn, { kind: "user" }> | undefined)?.text.replace(/\s+/g, " ").trim().slice(0, 80)
|
|
122
|
+
if (!h.title) delete h.title
|
|
123
|
+
const updated = iso(last)
|
|
124
|
+
if (updated) h.updatedAt = updated
|
|
125
|
+
if (continued) h.parent = { id: continued, kind: "continued" }
|
|
126
|
+
else if (typeof start.parent === "string" && start.parent) h.parent = { id: `factory-droid:${start.parent}`, kind: "fork" }
|
|
127
|
+
else if (typeof start.callingSessionId === "string" && start.callingSessionId) h.parent = { id: `factory-droid:${start.callingSessionId}`, kind: "subagent" }
|
|
128
|
+
lost.flush(h)
|
|
129
|
+
return { header: h, turns }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function at(v: unknown): { at?: string } {
|
|
133
|
+
const t = iso(v)
|
|
134
|
+
return t ? { at: t } : {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The session_start line and the first request, from the start and end of a file; undefined for a file with no messages. */
|
|
138
|
+
async function peek(path: string): Promise<Omit<SessionRef, "tool" | "path"> | undefined> {
|
|
139
|
+
const file = Bun.file(path)
|
|
140
|
+
const text = file.size <= 4 * PEEK ? await file.text() : `${await file.slice(0, PEEK).text()}\n${await file.slice(file.size - PEEK).text()}`
|
|
141
|
+
const events = parseEvents(text)
|
|
142
|
+
const start = events.find((e) => e.type === "session_start")
|
|
143
|
+
// Droid's picker leaves out empty sessions and mission workers.
|
|
144
|
+
if (!start || start.decompSessionType === "worker" || !events.some((e) => e.type === "message")) return undefined
|
|
145
|
+
const first = events.find((e) => e.type === "message" && e.message?.role === "user" && shared(e.message) && !(e.message.content as Event[] | undefined)?.some?.((b) => b?.type === "tool_result"))
|
|
146
|
+
const named = typeof start.title === "string" && start.title.trim() && start.title !== DEFAULT_TITLE ? start.title.trim() : undefined
|
|
147
|
+
const title = named ?? (first ? textOf(first.message.content).replace(/\s+/g, " ").trim().slice(0, 80) || undefined : undefined)
|
|
148
|
+
return {
|
|
149
|
+
sourceId: String(start.id),
|
|
150
|
+
...(title ? { title } : {}),
|
|
151
|
+
...(typeof start.cwd === "string" && start.cwd ? { cwd: start.cwd } : {}),
|
|
152
|
+
...(mtime(path) ? { updatedAt: mtime(path) } : {}),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const factoryDroidReader: Reader = {
|
|
157
|
+
tool: "factory-droid",
|
|
158
|
+
label: "Factory Droid",
|
|
159
|
+
|
|
160
|
+
async list(home: string): Promise<SessionRef[]> {
|
|
161
|
+
const root = sessionsRoot(home)
|
|
162
|
+
const refs: SessionRef[] = []
|
|
163
|
+
for (const project of ls(root)) {
|
|
164
|
+
const dir = join(root, project)
|
|
165
|
+
for (const name of ls(dir)) {
|
|
166
|
+
if (!name.endsWith(".jsonl")) continue
|
|
167
|
+
const path = join(dir, name)
|
|
168
|
+
try {
|
|
169
|
+
if (!statSync(path).isFile()) continue
|
|
170
|
+
const p = await peek(path)
|
|
171
|
+
if (p) refs.push({ tool: "factory-droid", path, ...p })
|
|
172
|
+
} catch {
|
|
173
|
+
// Deleted or unreadable between listing and reading: not a session to offer.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async read(ref: SessionRef, home: string): Promise<Session> {
|
|
181
|
+
if (!exists(ref.path)) throw new Error(`no Factory Droid session at ${ref.path}`)
|
|
182
|
+
return parseSession(readFileSync(ref.path, "utf8"), ref.path, home)
|
|
183
|
+
},
|
|
184
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
import type { ToolKind } from "../../session/types.ts"
|
|
4
|
+
import { toolKindOf } from "../../session/util.ts"
|
|
5
|
+
|
|
6
|
+
// Facts about Factory Droid's session files, read from the JavaScript bundled in the droid 1.6.0 binary
|
|
7
|
+
// (the session store's loader, the message serializer and the tool definitions). Droid is closed source;
|
|
8
|
+
// see docs/sessions/2026-11-08-session-formats.md, entry 51.
|
|
9
|
+
//
|
|
10
|
+
// ~/.factory/sessions/<project key>/<uuid>.jsonl (FACTORY_HOME_OVERRIDE moves ~/.factory's parent, which
|
|
11
|
+
// brain does not follow). The first line is {type:"session_start", id, title, owner, version:2, cwd}; a file
|
|
12
|
+
// without one is not a session. Then one event per line: "message" events hold Anthropic-style messages
|
|
13
|
+
// ({role:"user"|"assistant", content:[text|image|thinking|redacted_thinking|tool_use|tool_result]}),
|
|
14
|
+
// loaded in file order; a user message holding tool_result blocks is a tool message. A message's
|
|
15
|
+
// `visibility` is "user_only" for notices the model never sees and "llm_only" for context the person
|
|
16
|
+
// never sees. "compaction_state" events carry a summary and an anchor: the model's context is the summary
|
|
17
|
+
// plus the messages after the anchor. Other event types are bookkeeping. A <uuid>.settings.json beside the
|
|
18
|
+
// file holds the model and token totals; Droid creates it when it first opens a session without one.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The person's own words in a user message: Droid's reminders (and the ones Claude Code attaches to a message) come
|
|
22
|
+
* as `<system-reminder>` blocks, so those are taken off the front and what is left is theirs. A message that is only
|
|
23
|
+
* reminders has nothing of the person's in it. A session whose first words follow a reminder is not mistaken for one.
|
|
24
|
+
*/
|
|
25
|
+
export function personText(text: string): string {
|
|
26
|
+
return text.replace(/^(?:\s*<system-reminder>[\s\S]*?<\/system-reminder>)+/, "").trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Event = Record<string, any>
|
|
30
|
+
|
|
31
|
+
export const sessionsRoot = (home: string) => join(home, ".factory", "sessions")
|
|
32
|
+
|
|
33
|
+
/** The project key Droid files a folder's sessions under: "-" and the resolved path, its slashes turned into "-". */
|
|
34
|
+
export function projectKey(cwd: string): string {
|
|
35
|
+
let p = resolve(cwd)
|
|
36
|
+
try {
|
|
37
|
+
p = realpathSync(p)
|
|
38
|
+
} catch {
|
|
39
|
+
// a folder that does not exist yet: Droid uses the path as given
|
|
40
|
+
}
|
|
41
|
+
return `-${p.replace(/[\\/]+$/, "").replace(/^\/+/, "").replace(/\/+/g, "-")}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every event of a session file; none when there is no session_start line (Droid's own check). */
|
|
45
|
+
export function parseEvents(text: string): Event[] {
|
|
46
|
+
const out: Event[] = []
|
|
47
|
+
for (const line of text.split("\n")) {
|
|
48
|
+
if (!line.trim()) continue
|
|
49
|
+
try {
|
|
50
|
+
out.push(JSON.parse(line))
|
|
51
|
+
} catch {
|
|
52
|
+
// a half-written line: Droid skips it too
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out.some((e) => e?.type === "session_start" && typeof e.id === "string") ? out : []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Droid's built-in tools, by the name the model calls them. */
|
|
59
|
+
const DROID_KINDS: Record<string, ToolKind> = {
|
|
60
|
+
Read: "read",
|
|
61
|
+
Edit: "edit",
|
|
62
|
+
MultiEdit: "edit",
|
|
63
|
+
ApplyPatch: "edit",
|
|
64
|
+
Create: "write",
|
|
65
|
+
Execute: "shell",
|
|
66
|
+
LS: "list",
|
|
67
|
+
Grep: "search",
|
|
68
|
+
Glob: "search",
|
|
69
|
+
WebSearch: "web",
|
|
70
|
+
FetchUrl: "web",
|
|
71
|
+
Task: "task",
|
|
72
|
+
TodoWrite: "todo",
|
|
73
|
+
AskUser: "ask",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const kindOf = (name: string): ToolKind => DROID_KINDS[name] ?? toolKindOf(name)
|
|
77
|
+
|
|
78
|
+
/** JSON for one line, with the two line separators escaped as Droid writes them. */
|
|
79
|
+
export const line = (v: unknown) => JSON.stringify(v).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029")
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { userInfo } from "node:os"
|
|
4
|
+
import { dirname, extname, join } from "node:path"
|
|
5
|
+
import { toolLabel } from "../../brief.ts"
|
|
6
|
+
import { exists, isDir, versionOf } from "../../fsutil.ts"
|
|
7
|
+
import type { Session, Turn } from "../../session/types.ts"
|
|
8
|
+
import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
|
|
9
|
+
import { continuedNote } from "../claude-code/records.ts"
|
|
10
|
+
import { commandOf } from "../pi/writer.ts"
|
|
11
|
+
import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
|
|
12
|
+
import { parseSession } from "./reader.ts"
|
|
13
|
+
import { type Event, line, personText, projectKey, sessionsRoot } from "./records.ts"
|
|
14
|
+
|
|
15
|
+
// Continues a portable session as a new Factory Droid session: one file, <sessions>/<project key>/<uuid>.jsonl,
|
|
16
|
+
// with the session_start line and message events Droid writes itself (its own importedHistory path writes
|
|
17
|
+
// the same lines). `droid --resume <id>` loads it, its session picker and `droid search` list it, and Droid
|
|
18
|
+
// adds the .settings.json companion with the person's default model when it first opens the session.
|
|
19
|
+
//
|
|
20
|
+
// Messages are Anthropic-style, as Droid stores them: each response is one assistant message with its text
|
|
21
|
+
// and tool_use blocks, and the results of its calls follow in one user message of tool_result blocks.
|
|
22
|
+
// Calls from other tools become Droid's own Read / Create / Edit / Execute / Grep / Glob / LS when their
|
|
23
|
+
// arguments map; others keep their names. The note saying where the session came from is a user message
|
|
24
|
+
// with visibility "llm_only": the model reads it, the person never sees it. A compaction is a
|
|
25
|
+
// compaction_state whose anchor is the last message before it, so Droid continues from its summary while
|
|
26
|
+
// the earlier turns stay on screen; the note goes right after the last one, where the model's context starts.
|
|
27
|
+
// Readable reasoning is dropped: Droid's thinking blocks are sent back to the model and need the
|
|
28
|
+
// provider's signature.
|
|
29
|
+
|
|
30
|
+
/** droid versions whose session format this writer was checked against. */
|
|
31
|
+
export const DROID_TESTED_VERSIONS = ["1.6.0"]
|
|
32
|
+
|
|
33
|
+
const IMAGE_TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" }
|
|
34
|
+
const IMAGE_LIMIT = 5 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
type Call = Extract<Turn, { kind: "tool_call" }>
|
|
37
|
+
|
|
38
|
+
const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
|
|
39
|
+
|
|
40
|
+
const str = (o: Event, ...keys: string[]): string | undefined => {
|
|
41
|
+
for (const k of keys) if (typeof o[k] === "string" && o[k]) return o[k] as string
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
const num = (o: Event, key: string, as: string) => (typeof o[key] === "number" ? { [as]: o[key] } : {})
|
|
45
|
+
|
|
46
|
+
/** A call from another tool as one of Droid's built-in tools, when its arguments say enough; undefined keeps it as it was. */
|
|
47
|
+
export function asDroidTool(call: Call): { name: string; input: Event } | undefined {
|
|
48
|
+
const i = call.input && typeof call.input === "object" && !Array.isArray(call.input) ? (call.input as Event) : undefined
|
|
49
|
+
if (!i) return undefined
|
|
50
|
+
const path = str(i, "file_path", "path", "filePath", "absolute_path", "target_file")
|
|
51
|
+
switch (call.toolKind) {
|
|
52
|
+
case "read":
|
|
53
|
+
return path ? { name: "Read", input: { file_path: path, ...num(i, "offset", "offset"), ...num(i, "limit", "limit") } } : undefined
|
|
54
|
+
case "write": {
|
|
55
|
+
const content = i.content ?? i.contents ?? i.file_text
|
|
56
|
+
return path && typeof content === "string" ? { name: "Create", input: { file_path: path, content } } : undefined
|
|
57
|
+
}
|
|
58
|
+
case "edit": {
|
|
59
|
+
// One replacement only: Droid's Edit has no list of edits.
|
|
60
|
+
const one = Array.isArray(i.edits) ? (i.edits.length === 1 ? (i.edits[0] as Event) : undefined) : i
|
|
61
|
+
const oldStr = one?.old_string ?? one?.old_str ?? one?.oldText
|
|
62
|
+
const newStr = one?.new_string ?? one?.new_str ?? one?.newText
|
|
63
|
+
if (!path || typeof oldStr !== "string" || typeof newStr !== "string") return undefined
|
|
64
|
+
const all = i.replace_all === true || i.change_all === true
|
|
65
|
+
return { name: "Edit", input: { file_path: path, old_str: oldStr, new_str: newStr, ...(all ? { change_all: true } : {}) } }
|
|
66
|
+
}
|
|
67
|
+
case "shell": {
|
|
68
|
+
const command = commandOf(i)
|
|
69
|
+
return command ? { name: "Execute", input: { command } } : undefined
|
|
70
|
+
}
|
|
71
|
+
case "search": {
|
|
72
|
+
const where = str(i, "path", "folder", "dir_path", "directory")
|
|
73
|
+
if (/glob|find|file_search/i.test(call.name)) {
|
|
74
|
+
const patterns = i.patterns ?? i.pattern
|
|
75
|
+
return typeof patterns === "string" || (Array.isArray(patterns) && patterns.every((p) => typeof p === "string"))
|
|
76
|
+
? { name: "Glob", input: { patterns, ...(where ? { folder: where } : {}) } }
|
|
77
|
+
: undefined
|
|
78
|
+
}
|
|
79
|
+
const pattern = str(i, "pattern", "regex", "query")
|
|
80
|
+
const glob = str(i, "glob_pattern", "glob", "include")
|
|
81
|
+
return pattern ? { name: "Grep", input: { pattern, ...(where ? { path: where } : {}), ...(glob ? { glob_pattern: glob } : {}) } } : undefined
|
|
82
|
+
}
|
|
83
|
+
case "list": {
|
|
84
|
+
const where = str(i, "directory_path", "path", "dir_path", "target_directory", "directory", "folder")
|
|
85
|
+
return { name: "LS", input: where ? { directory_path: where } : {} }
|
|
86
|
+
}
|
|
87
|
+
default:
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface DroidPlan {
|
|
93
|
+
/** The session_start line and every event, in order. */
|
|
94
|
+
lines: Event[]
|
|
95
|
+
lost: string[]
|
|
96
|
+
/** What a read-back must find. */
|
|
97
|
+
expect: { users: number; assistants: number; calls: number }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The lines of a new Droid session `id` continuing `session`. Pure apart from reading attached images. */
|
|
101
|
+
export function planDroidSession(session: Session, opts: { id: string; now: Date; cwd: string; owner: string }): DroidPlan {
|
|
102
|
+
const drops = new Map<string, number>()
|
|
103
|
+
const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
|
|
104
|
+
const label = toolLabel(session.header.tool)
|
|
105
|
+
const expect = { users: 0, assistants: 0, calls: 0 }
|
|
106
|
+
const title = session.header.title ?? firstUserText(session)?.replace(/\s+/g, " ").trim().slice(0, 60) ?? "Session"
|
|
107
|
+
// A title set by hand keeps Droid from asking a model to name the session.
|
|
108
|
+
const lines: Event[] = [{ type: "session_start", id: opts.id, title: `${title} (from ${label})`, isSessionTitleManuallySet: true, owner: opts.owner, version: 2, cwd: opts.cwd }]
|
|
109
|
+
|
|
110
|
+
// Timestamps never go backwards; turns without one take the previous time.
|
|
111
|
+
const known = session.turns.map((t) => (t.at ? Date.parse(t.at) : Number.NaN)).find(Number.isFinite)
|
|
112
|
+
let clock = (known ?? opts.now.getTime()) - 1
|
|
113
|
+
const stamp = (at?: string) => {
|
|
114
|
+
const t = at ? Date.parse(at) : Number.NaN
|
|
115
|
+
clock = Number.isFinite(t) && t > clock ? t : clock + 1
|
|
116
|
+
return new Date(clock).toISOString()
|
|
117
|
+
}
|
|
118
|
+
let parent: string | undefined
|
|
119
|
+
let count = 0
|
|
120
|
+
const message = (m: Event, at?: string) => {
|
|
121
|
+
const id = randomUUID()
|
|
122
|
+
lines.push({ type: "message", id, timestamp: stamp(at), ...(parent ? { parentId: parent } : {}), message: m })
|
|
123
|
+
parent = id
|
|
124
|
+
count++
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const note = () => message({ role: "user", content: [{ type: "text", text: continuedNote(label, session.header.id, opts.now.toISOString().slice(0, 10)) }], visibility: "llm_only" })
|
|
128
|
+
const lastCompaction = session.turns.map((t) => t.kind).lastIndexOf("compaction")
|
|
129
|
+
if (lastCompaction < 0) note()
|
|
130
|
+
|
|
131
|
+
// Call ids as the Anthropic format allows them.
|
|
132
|
+
const ids = new Map<string, string>()
|
|
133
|
+
const taken = new Set<string>()
|
|
134
|
+
let remapped = 0
|
|
135
|
+
let renamed = 0
|
|
136
|
+
const callId = (id: string) => {
|
|
137
|
+
let v = ids.get(id)
|
|
138
|
+
if (v) return v
|
|
139
|
+
v = /^[A-Za-z0-9_-]{1,64}$/.test(id) && !taken.has(id) ? id : `toolu_${randomUUID().replace(/-/g, "").slice(0, 24)}`
|
|
140
|
+
if (v !== id) remapped++
|
|
141
|
+
ids.set(id, v)
|
|
142
|
+
taken.add(v)
|
|
143
|
+
return v
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const written = new Set<string>()
|
|
147
|
+
const done = new Set<string>()
|
|
148
|
+
let cur: { at?: string; blocks: Event[]; calls: string[] } | undefined
|
|
149
|
+
// The results of the last response's calls, until something else comes.
|
|
150
|
+
let res: { at?: string; blocks: Event[]; calls: string[] } | undefined
|
|
151
|
+
const closeResults = () => {
|
|
152
|
+
if (!res) return
|
|
153
|
+
const r = res
|
|
154
|
+
res = undefined
|
|
155
|
+
// Every call is answered before the next request; a call that never got a result is closed as an error.
|
|
156
|
+
for (const id of r.calls) {
|
|
157
|
+
if (done.has(id)) continue
|
|
158
|
+
drop("tool calls without a result (closed as errors)")
|
|
159
|
+
r.blocks.push({ type: "tool_result", tool_use_id: callId(id), content: "No result was recorded for this call; the session moved on before it finished.", is_error: true })
|
|
160
|
+
done.add(id)
|
|
161
|
+
}
|
|
162
|
+
message({ role: "user", content: r.blocks }, r.at)
|
|
163
|
+
}
|
|
164
|
+
const flush = () => {
|
|
165
|
+
if (cur?.blocks.length) {
|
|
166
|
+
const c = cur
|
|
167
|
+
message({ role: "assistant", content: c.blocks }, c.at)
|
|
168
|
+
if (c.calls.length) res = { blocks: [], calls: c.calls }
|
|
169
|
+
}
|
|
170
|
+
cur = undefined
|
|
171
|
+
}
|
|
172
|
+
const settle = () => {
|
|
173
|
+
flush()
|
|
174
|
+
closeResults()
|
|
175
|
+
}
|
|
176
|
+
const assistant = (at?: string) => (cur ??= { ...(at ? { at } : {}), blocks: [], calls: [] })
|
|
177
|
+
|
|
178
|
+
for (const [i, t] of session.turns.entries()) {
|
|
179
|
+
switch (t.kind) {
|
|
180
|
+
case "user": {
|
|
181
|
+
settle()
|
|
182
|
+
const blocks: Event[] = []
|
|
183
|
+
let text = t.text
|
|
184
|
+
for (const a of t.attachments ?? []) {
|
|
185
|
+
if (!a.path) continue // bytes the reader could not keep; it already counted them
|
|
186
|
+
if (a.kind === "file") {
|
|
187
|
+
text += `\n\n[Attached file: ${a.path}]`
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
const mime = a.mime ?? IMAGE_TYPES[extname(a.path).toLowerCase()]
|
|
191
|
+
let size = Number.POSITIVE_INFINITY
|
|
192
|
+
try {
|
|
193
|
+
size = statSync(a.path).size
|
|
194
|
+
} catch {}
|
|
195
|
+
if (mime && Object.values(IMAGE_TYPES).includes(mime) && size <= IMAGE_LIMIT)
|
|
196
|
+
blocks.push({ type: "image", source: { type: "base64", media_type: mime, data: readFileSync(a.path).toString("base64") } })
|
|
197
|
+
else drop("images that could not be attached")
|
|
198
|
+
}
|
|
199
|
+
if (!text.trim() && !blocks.length) break
|
|
200
|
+
message({ role: "user", content: [{ type: "text", text: text.trim() ? text : "(image)" }, ...blocks] }, t.at)
|
|
201
|
+
// A message that is only reminders reads back as none of the person's, as Droid's own reminders do.
|
|
202
|
+
if (personText(text) || blocks.length) expect.users++
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
case "assistant":
|
|
206
|
+
// Text after a tool call is a new response: it was written after the call's result came back.
|
|
207
|
+
if (cur?.calls.length) flush()
|
|
208
|
+
closeResults()
|
|
209
|
+
if (!t.text.trim()) break
|
|
210
|
+
assistant(t.at).blocks.push({ type: "text", text: t.text })
|
|
211
|
+
expect.assistants++
|
|
212
|
+
break
|
|
213
|
+
case "reasoning":
|
|
214
|
+
if (t.text.trim()) drop("reasoning (Droid sends thinking back to the model, which needs the provider's signature)")
|
|
215
|
+
break
|
|
216
|
+
case "tool_call": {
|
|
217
|
+
if (written.has(t.id)) {
|
|
218
|
+
drop("repeated tool call ids")
|
|
219
|
+
break
|
|
220
|
+
}
|
|
221
|
+
closeResults()
|
|
222
|
+
const d = asDroidTool(t)
|
|
223
|
+
// Unmapped names must still suit the Anthropic tool-name rule.
|
|
224
|
+
const name = d?.name ?? (t.name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "tool")
|
|
225
|
+
if (name !== t.name) renamed++
|
|
226
|
+
const input = d?.input ?? (t.input && typeof t.input === "object" && !Array.isArray(t.input) ? (t.input as Event) : { input: t.input ?? null })
|
|
227
|
+
assistant(t.at).blocks.push({ type: "tool_use", id: callId(t.id), name, input })
|
|
228
|
+
cur!.calls.push(t.id)
|
|
229
|
+
written.add(t.id)
|
|
230
|
+
expect.calls++
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
case "tool_result": {
|
|
234
|
+
if (!written.has(t.callId)) {
|
|
235
|
+
drop("tool results without a call")
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
if (done.has(t.callId)) {
|
|
239
|
+
drop("repeated or late tool results")
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
// A result closes the response that made the call.
|
|
243
|
+
if (cur?.calls.includes(t.callId)) flush()
|
|
244
|
+
if (!res?.calls.includes(t.callId)) {
|
|
245
|
+
drop("repeated or late tool results")
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
res.at ??= t.at
|
|
249
|
+
const output = t.fullPath ? `${t.output}\n\n[Full output: ${t.fullPath}]` : t.output
|
|
250
|
+
res.blocks.push({ type: "tool_result", tool_use_id: callId(t.callId), content: output.trim() ? output : "(no output)", ...(t.ok ? {} : { is_error: true }) })
|
|
251
|
+
done.add(t.callId)
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
case "compaction": {
|
|
255
|
+
settle()
|
|
256
|
+
// The anchor is the last message the summary covers; with none it summarises what came before this session.
|
|
257
|
+
const anchor = parent ? { anchorMessage: { id: parent, index: count - 1 } } : {}
|
|
258
|
+
lines.push({ type: "compaction_state", id: randomUUID(), timestamp: stamp(t.at), summaryText: t.summary, summaryTokens: Math.round(t.summary.length / 4), summaryKind: "llm_summary", ...anchor, removedCount: count })
|
|
259
|
+
if (i === lastCompaction) note()
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
case "file_edit":
|
|
263
|
+
if (!t.callId || !written.has(t.callId)) drop("file edits outside a tool call")
|
|
264
|
+
break
|
|
265
|
+
case "subagent":
|
|
266
|
+
drop("subagent transcripts (their results stay in the parent's tool results)")
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
settle()
|
|
271
|
+
if (!expect.users && !expect.assistants && !expect.calls) throw new Error("nothing to continue: the session has no messages or tool calls")
|
|
272
|
+
|
|
273
|
+
const lost = [...drops].map(([what, n]) => `${what} (${n})`)
|
|
274
|
+
if (renamed) lost.push(`original tool names (${renamed} calls renamed to Droid's tools or to fit its rules)`)
|
|
275
|
+
if (remapped) lost.push(`tool call ids remapped (${remapped})`)
|
|
276
|
+
return { lines, lost, expect }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** droid's version from `droid --version` ("1.6.0"), or undefined when it is missing. */
|
|
280
|
+
export function droidVersion(): string | undefined {
|
|
281
|
+
return versionOf("droid")?.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)?.[1]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Droid records the folder as the process saw it: the resolved path (/private/tmp, not /tmp). */
|
|
285
|
+
function realCwd(cwd: string): string {
|
|
286
|
+
try {
|
|
287
|
+
return realpathSync(cwd)
|
|
288
|
+
} catch {
|
|
289
|
+
return cwd
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function currentUser(): string {
|
|
294
|
+
try {
|
|
295
|
+
return userInfo().username
|
|
296
|
+
} catch {
|
|
297
|
+
return "unknown"
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** `version` and `owner` are for tests, which must not depend on the installed binary or the account. */
|
|
302
|
+
export function factoryDroidWriter(o: { version?: string; owner?: string } = {}): Writer {
|
|
303
|
+
const installed = () => o.version ?? droidVersion()
|
|
304
|
+
return {
|
|
305
|
+
tool: "factory-droid",
|
|
306
|
+
label: "Factory Droid",
|
|
307
|
+
way: "native",
|
|
308
|
+
|
|
309
|
+
async ready(w: WriteOptions): Promise<Readiness> {
|
|
310
|
+
const v = installed()
|
|
311
|
+
if (!v) return { ok: false, why: "Factory Droid is not installed here (`droid --version` failed)" }
|
|
312
|
+
if (!DROID_TESTED_VERSIONS.includes(v)) return { ok: false, why: `Factory Droid ${v} is not a version this writer was checked against (${DROID_TESTED_VERSIONS.join(", ")})` }
|
|
313
|
+
if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Droid keeps sessions by the folder they belong to` }
|
|
314
|
+
return { ok: true }
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
async write(session: Session, w: WriteOptions): Promise<WriteResult> {
|
|
318
|
+
const cwd = realCwd(w.cwd)
|
|
319
|
+
const dir = join(sessionsRoot(w.home), projectKey(cwd))
|
|
320
|
+
let id = randomUUID()
|
|
321
|
+
while (exists(join(dir, `${id}.jsonl`))) id = randomUUID()
|
|
322
|
+
const path = join(dir, `${id}.jsonl`)
|
|
323
|
+
const plan = planDroidSession(session, { id, now: new Date(), cwd, owner: o.owner ?? currentUser() })
|
|
324
|
+
if (!w.dryRun) {
|
|
325
|
+
// Droid keeps its session folders and files private to the account.
|
|
326
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
|
|
327
|
+
// "wx": a new file only; an existing session is never touched.
|
|
328
|
+
writeFileSync(path, `${plan.lines.map(line).join("\n")}\n`, { flag: "wx", mode: 0o600 })
|
|
329
|
+
const back = parseSession(readFileSync(path, "utf8"), path, w.home)
|
|
330
|
+
const n = countTurns(back)
|
|
331
|
+
const pairs = unpaired(back)
|
|
332
|
+
if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
|
|
333
|
+
rmSync(path, { force: true })
|
|
334
|
+
throw new Error(
|
|
335
|
+
`the Droid session did not read back as written (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}); nothing was kept`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return {
|
|
340
|
+
tool: "factory-droid",
|
|
341
|
+
way: "native",
|
|
342
|
+
sessionId: id,
|
|
343
|
+
written: [path],
|
|
344
|
+
open: `cd ${shellQuote(cwd)} && droid --resume ${id}`,
|
|
345
|
+
lost: plan.lost,
|
|
346
|
+
dryRun: !!w.dryRun,
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
}
|