opencode-context-tree 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/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/dist/server.js +19792 -0
- package/dist/tui.js +22742 -0
- package/docs/USAGE.md +155 -0
- package/package.json +93 -0
- package/src/core/actions.ts +53 -0
- package/src/core/adopt.ts +93 -0
- package/src/core/consumers.ts +39 -0
- package/src/core/crop.ts +176 -0
- package/src/core/cropplan.ts +201 -0
- package/src/core/ctree-args.ts +89 -0
- package/src/core/decision.ts +97 -0
- package/src/core/journal.ts +343 -0
- package/src/core/lanes.ts +149 -0
- package/src/core/navigation.ts +95 -0
- package/src/core/tokens.ts +105 -0
- package/src/core/transcript.ts +124 -0
- package/src/core/tree.ts +738 -0
- package/src/core/undo.ts +52 -0
- package/src/server/index.ts +278 -0
- package/src/shared/adopt.ts +82 -0
- package/src/shared/debug.ts +9 -0
- package/src/shared/store.ts +286 -0
- package/src/tui/actions.ts +447 -0
- package/src/tui/editor.ts +45 -0
- package/src/tui/index.tsx +421 -0
- package/src/tui/route.tsx +1058 -0
- package/src/tui/transcripts.ts +55 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Journal IO (DESIGN.md §4.1, §4.2, §8 "server/ journal IO (mtime cache)").
|
|
3
|
+
*
|
|
4
|
+
* Reads and appends `.opencode/context-tree/<treeId>.jsonl` plus its
|
|
5
|
+
* `registry.json` (sessionID → treeId), local to a worktree. This is the only
|
|
6
|
+
* place in the plugin that touches the filesystem for journal state.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
import { foldJournal, parseJournal, type JournalActor, type JournalEntry, type TreeState } from "../core/journal.js"
|
|
11
|
+
|
|
12
|
+
export type StorageMode = "local" | "global"
|
|
13
|
+
|
|
14
|
+
export type JournalStoreOptions = {
|
|
15
|
+
/** The git worktree root (DESIGN.md §4.1's `local` storage default). */
|
|
16
|
+
worktree: string
|
|
17
|
+
/** `opencode`'s state directory, used only when `mode` is `"global"`. */
|
|
18
|
+
stateDir?: string
|
|
19
|
+
mode?: StorageMode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type CacheEntry = {
|
|
23
|
+
key: string
|
|
24
|
+
entries: JournalEntry[]
|
|
25
|
+
state: TreeState
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const REGISTRY_FILE = "registry.json"
|
|
29
|
+
/** Past this, a lock is assumed to belong to a crashed writer: it is removed and taken over. */
|
|
30
|
+
const LOCK_TIMEOUT_MS = 250
|
|
31
|
+
|
|
32
|
+
export class JournalStore {
|
|
33
|
+
private readonly baseDir: string
|
|
34
|
+
private readonly cache = new Map<string, CacheEntry>()
|
|
35
|
+
private registryCache: { key: string; data: Record<string, string> } | undefined
|
|
36
|
+
|
|
37
|
+
constructor(options: JournalStoreOptions) {
|
|
38
|
+
// OpenCode reports worktree "/" for directories outside git; never write at the fs root
|
|
39
|
+
const local = options.mode !== "global" && !isFsRoot(options.worktree)
|
|
40
|
+
this.baseDir = local
|
|
41
|
+
? path.join(options.worktree, ".opencode", "context-tree")
|
|
42
|
+
: path.join(options.stateDir ?? defaultStateDir(), "plugins", "opencode-context-tree")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where this store keeps its files (for messages and tests). */
|
|
46
|
+
get dir(): string {
|
|
47
|
+
return this.baseDir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private ensureDir(): void {
|
|
51
|
+
if (fs.existsSync(this.baseDir)) return
|
|
52
|
+
fs.mkdirSync(this.baseDir, { recursive: true })
|
|
53
|
+
const gitignorePath = path.join(this.baseDir, ".gitignore")
|
|
54
|
+
if (!fs.existsSync(gitignorePath)) fs.writeFileSync(gitignorePath, "*\n")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private journalPath(treeId: string): string {
|
|
58
|
+
return path.join(this.baseDir, `${treeId}.jsonl`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private registryPath(): string {
|
|
62
|
+
return path.join(this.baseDir, REGISTRY_FILE)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** sessionID -> treeId, read fresh only when the registry file has changed. */
|
|
66
|
+
readRegistry(): Record<string, string> {
|
|
67
|
+
const registryPath = this.registryPath()
|
|
68
|
+
const key = statKey(registryPath)
|
|
69
|
+
// unreadable (removed, EACCES) must never take a hook down: keep the last good copy
|
|
70
|
+
if (key === undefined) return this.registryCache?.data ?? {}
|
|
71
|
+
if (this.registryCache && this.registryCache.key === key) return this.registryCache.data
|
|
72
|
+
let data: Record<string, string> = this.registryCache?.data ?? {}
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")) as unknown
|
|
75
|
+
if (parsed && typeof parsed === "object") data = Object.fromEntries(Object.entries(parsed as Record<string, unknown>).filter(([, v]) => typeof v === "string")) as Record<string, string>
|
|
76
|
+
} catch {
|
|
77
|
+
// a truncated/corrupt registry keeps the last good copy too
|
|
78
|
+
}
|
|
79
|
+
this.registryCache = { key, data }
|
|
80
|
+
return data
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
treeIdFor(sessionID: string): string | undefined {
|
|
84
|
+
return this.readRegistry()[sessionID]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Serialize the registry's read-modify-write against the other plugin half (DESIGN.md §8).
|
|
89
|
+
* Blocking is fine: the critical section is one small write, and a crashed holder's lock
|
|
90
|
+
* is broken rather than obeyed.
|
|
91
|
+
*/
|
|
92
|
+
private withRegistryLock<T>(fn: () => T): T {
|
|
93
|
+
const lockPath = `${this.registryPath()}.lock`
|
|
94
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
95
|
+
let fd: number | undefined
|
|
96
|
+
let broke = false
|
|
97
|
+
for (;;) {
|
|
98
|
+
try {
|
|
99
|
+
fd = fs.openSync(lockPath, "wx")
|
|
100
|
+
break
|
|
101
|
+
} catch {
|
|
102
|
+
// a lock older than the wait window belongs to a crashed writer: remove it once,
|
|
103
|
+
// rather than have every later write busy-wait the whole window inside a hook
|
|
104
|
+
if (!broke && lockAgeMs(lockPath) >= LOCK_TIMEOUT_MS) {
|
|
105
|
+
broke = true
|
|
106
|
+
try {
|
|
107
|
+
fs.unlinkSync(lockPath)
|
|
108
|
+
} catch {}
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (Date.now() >= deadline) {
|
|
112
|
+
// the whole window has passed: a lock that predates our wait belongs to a dead
|
|
113
|
+
// holder (the critical section is one small write), so break it instead of
|
|
114
|
+
// leaving it to tax every later write with another full wait
|
|
115
|
+
// mtimeMs is sub-millisecond while Date.now() is not, so "predates our start" can
|
|
116
|
+
// miss a same-millisecond lock; "held longer than any critical section" cannot
|
|
117
|
+
if (lockMtimeMs(lockPath) <= Date.now() - 10) {
|
|
118
|
+
try {
|
|
119
|
+
fs.unlinkSync(lockPath)
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
sleepSync(5)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
return fn()
|
|
129
|
+
} finally {
|
|
130
|
+
if (fd !== undefined) {
|
|
131
|
+
try {
|
|
132
|
+
fs.closeSync(fd)
|
|
133
|
+
fs.unlinkSync(lockPath)
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Registry read-modify-write; the caller must hold the registry lock. */
|
|
140
|
+
private putLocked(sessionID: string, treeId: string): void {
|
|
141
|
+
this.registryCache = undefined // the other half may have written while we waited for the lock
|
|
142
|
+
const registry = { ...this.readRegistry(), [sessionID]: treeId }
|
|
143
|
+
// atomic: temp file + rename, so a concurrent reader never sees a partial file
|
|
144
|
+
const tmp = `${this.registryPath()}.${process.pid}.${Date.now()}.tmp`
|
|
145
|
+
fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`)
|
|
146
|
+
fs.renameSync(tmp, this.registryPath())
|
|
147
|
+
this.registryCache = undefined
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Registers a session under a tree, creating the tree if this is its first session. */
|
|
151
|
+
registerSession(sessionID: string, treeId: string): void {
|
|
152
|
+
this.ensureDir()
|
|
153
|
+
if (this.readRegistry()[sessionID] === treeId) return
|
|
154
|
+
this.withRegistryLock(() => this.putLocked(sessionID, treeId))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Append one journal line. Append-only: existing lines are never rewritten. */
|
|
158
|
+
append(treeId: string, entry: JournalEntry): void {
|
|
159
|
+
this.ensureDir()
|
|
160
|
+
fs.appendFileSync(this.journalPath(treeId), `${JSON.stringify(entry)}\n`)
|
|
161
|
+
this.cache.delete(treeId) // our own append must be visible even inside one filesystem mtime tick
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Read + parse a tree's journal, with a stat-checked cache so repeated reads within one
|
|
165
|
+
* transform hook (DESIGN.md §8's "sub-millisecond mtime check") are cheap. */
|
|
166
|
+
private readEntries(treeId: string): JournalEntry[] {
|
|
167
|
+
const cached = this.cache.get(treeId)
|
|
168
|
+
const key = statKey(this.journalPath(treeId))
|
|
169
|
+
if (key === undefined) return cached?.entries ?? []
|
|
170
|
+
if (cached && cached.key === key) return cached.entries
|
|
171
|
+
let raw: string
|
|
172
|
+
try {
|
|
173
|
+
raw = fs.readFileSync(this.journalPath(treeId), "utf8")
|
|
174
|
+
} catch {
|
|
175
|
+
return cached?.entries ?? []
|
|
176
|
+
}
|
|
177
|
+
const entries = parseJournal(raw)
|
|
178
|
+
const state = foldJournal(entries, treeId)
|
|
179
|
+
this.cache.set(treeId, { key, entries, state })
|
|
180
|
+
return entries
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Raw journal entries of a tree in file order (for undo planning). */
|
|
184
|
+
entriesFor(treeId: string): JournalEntry[] {
|
|
185
|
+
return this.readEntries(treeId)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Folded tree state for a tree, from cache when the journal file hasn't changed. */
|
|
189
|
+
stateFor(treeId: string): TreeState {
|
|
190
|
+
this.readEntries(treeId) // populates/refreshes the cache as a side effect
|
|
191
|
+
const cached = this.cache.get(treeId)
|
|
192
|
+
return cached?.state ?? foldJournal([], treeId)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Build a journal envelope. IDs are time-sortable so a fold's tie-breaks are stable. */
|
|
196
|
+
static entry<T extends JournalEntry["type"]>(
|
|
197
|
+
type: T,
|
|
198
|
+
data: Extract<JournalEntry, { type: T }>["data"],
|
|
199
|
+
actor: JournalActor,
|
|
200
|
+
): JournalEntry {
|
|
201
|
+
return { v: 1, id: `e_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`, ts: Date.now(), type, actor, data } as JournalEntry
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Append a typed entry (envelope built here). Returns the entry so callers can reference its id. */
|
|
205
|
+
record<T extends JournalEntry["type"]>(
|
|
206
|
+
treeId: string,
|
|
207
|
+
type: T,
|
|
208
|
+
data: Extract<JournalEntry, { type: T }>["data"],
|
|
209
|
+
actor: JournalActor,
|
|
210
|
+
): JournalEntry {
|
|
211
|
+
// generic forwarding trips TS's intersection of all data shapes; the public signature stays typed
|
|
212
|
+
const entry = JournalStore.entry(type, data as never, actor)
|
|
213
|
+
this.append(treeId, entry)
|
|
214
|
+
return entry
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The tree a session belongs to, creating a fresh tree rooted at the session when it has none. */
|
|
218
|
+
ensureTree(sessionID: string, actor: JournalActor): string {
|
|
219
|
+
const existing = this.treeIdFor(sessionID)
|
|
220
|
+
if (existing) return existing
|
|
221
|
+
this.ensureDir()
|
|
222
|
+
// minting and registering must be one critical section: both halves adopt the same
|
|
223
|
+
// `session.created`, and an unlocked read-then-write mints two trees for one session
|
|
224
|
+
return this.withRegistryLock(() => {
|
|
225
|
+
this.registryCache = undefined
|
|
226
|
+
const raced = this.readRegistry()[sessionID]
|
|
227
|
+
if (raced) return raced
|
|
228
|
+
const treeId = `t_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 6)}`
|
|
229
|
+
this.putLocked(sessionID, treeId)
|
|
230
|
+
this.record(treeId, "tree.created", { rootSessionID: sessionID }, actor)
|
|
231
|
+
return treeId
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Folded tree state for a session, or `undefined` if the session has no tree yet. */
|
|
236
|
+
stateForSession(sessionID: string): TreeState | undefined {
|
|
237
|
+
const treeId = this.treeIdFor(sessionID)
|
|
238
|
+
if (!treeId) return undefined
|
|
239
|
+
return this.stateFor(treeId)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Cache key: mtime alone cannot separate two writes inside one filesystem tick. */
|
|
244
|
+
function statKey(file: string): string | undefined {
|
|
245
|
+
try {
|
|
246
|
+
const st = fs.statSync(file)
|
|
247
|
+
return `${st.mtimeMs}:${st.size}`
|
|
248
|
+
} catch {
|
|
249
|
+
return undefined
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** When a lock file was created; `Infinity` when it is already gone (so "predates X" is false). */
|
|
254
|
+
function lockMtimeMs(lockPath: string): number {
|
|
255
|
+
try {
|
|
256
|
+
return fs.statSync(lockPath).mtimeMs
|
|
257
|
+
} catch {
|
|
258
|
+
return Infinity
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** How long a lock file has existed; `Infinity` when it is already gone. */
|
|
263
|
+
function lockAgeMs(lockPath: string): number {
|
|
264
|
+
try {
|
|
265
|
+
return Date.now() - fs.statSync(lockPath).mtimeMs
|
|
266
|
+
} catch {
|
|
267
|
+
return Infinity
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4))
|
|
272
|
+
|
|
273
|
+
function sleepSync(ms: number): void {
|
|
274
|
+
Atomics.wait(SLEEP_SLOT, 0, 0, ms)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isFsRoot(p: string): boolean {
|
|
278
|
+
if (!p) return true
|
|
279
|
+
const abs = path.resolve(p)
|
|
280
|
+
return abs === path.parse(abs).root
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function defaultStateDir(): string {
|
|
284
|
+
if (process.env["XDG_STATE_HOME"]) return path.join(process.env["XDG_STATE_HOME"], "opencode")
|
|
285
|
+
return path.join(process.env["HOME"] ?? "", ".local", "state", "opencode")
|
|
286
|
+
}
|