lua: implement GCCWorkspace with file-based persistence

Swadhin Biswas committed 7 months ago
aa36924
1 parent e68f19d
commit aa36924
1 file changed
+482 -0
Showing 1 changed file+482-0
▼LUA/contexa/workspace.lua
+482-0
1
+--- contexa — Git-inspired context management for LLM agents
2
+--- GCCWorkspace: persistent versioned memory workspace.
3
+--- Based on arXiv:2508.00031.
4
+---
5
+--- @module contexa.workspace
6
+
7
+local models = require("contexa.models")
8
+local lfs_ok, lfs = pcall(require, "lfs")
9
+
10
+local W = {}
11
+W.__index = W
12
+
13
+local MAIN_BRANCH = "main"
14
+local GCC_DIR = ".GCC"
15
+
16
+------------------------------------------------------------------------
17
+-- Filesystem helpers
18
+------------------------------------------------------------------------
19
+
20
+local function path_join(...)
21
+ local parts = { ... }
22
+ return table.concat(parts, "/")
23
+end
24
+
25
+local function dir_exists(path)
26
+ if lfs_ok then
27
+ local attr = lfs.attributes(path)
28
+ return attr and attr.mode == "directory"
29
+ end
30
+ -- fallback: try to open directory
31
+ local ok, _, code = os.rename(path, path)
32
+ if ok then return true end
33
+ -- code 13 = permission denied (exists but can't rename)
34
+ return code == 13
35
+end
36
+
37
+local function file_exists(path)
38
+ local f = io.open(path, "r")
39
+ if f then f:close(); return true end
40
+ return false
41
+end
42
+
43
+local function mkdir_p(path)
44
+ if lfs_ok then
45
+ -- split and create each segment
46
+ local accum = ""
47
+ for seg in path:gmatch("[^/]+") do
48
+ accum = accum == "" and seg or (accum .. "/" .. seg)
49
+ if not dir_exists(accum) then
50
+ lfs.mkdir(accum)
51
+ end
52
+ end
53
+ return
54
+ end
55
+ -- fallback: use os.execute
56
+ os.execute('mkdir -p "' .. path .. '"')
57
+end
58
+
59
+local function read_file(path)
60
+ local f = io.open(path, "r")
61
+ if not f then return "" end
62
+ local content = f:read("*a")
63
+ f:close()
64
+ return content or ""
65
+end
66
+
67
+local function write_file(path, content)
68
+ local f = io.open(path, "w")
69
+ if not f then error("Cannot write to " .. path) end
70
+ f:write(content)
71
+ f:close()
72
+end
73
+
74
+local function append_file(path, content)
75
+ local f = io.open(path, "a")
76
+ if not f then error("Cannot append to " .. path) end
77
+ f:write(content)
78
+ f:close()
79
+end
80
+
81
+local function list_dirs(path)
82
+ local dirs = {}
83
+ if lfs_ok then
84
+ for entry in lfs.dir(path) do
85
+ if entry ~= "." and entry ~= ".." then
86
+ local full = path_join(path, entry)
87
+ local attr = lfs.attributes(full)
88
+ if attr and attr.mode == "directory" then
89
+ dirs[#dirs + 1] = entry
90
+ end
91
+ end
92
+ end
93
+ else
94
+ -- fallback: use ls
95
+ local handle = io.popen('ls -1 "' .. path .. '" 2>/dev/null')
96
+ if handle then
97
+ for line in handle:lines() do
98
+ if line ~= "" then
99
+ local full = path_join(path, line)
100
+ if dir_exists(full) then
101
+ dirs[#dirs + 1] = line
102
+ end
103
+ end
104
+ end
105
+ handle:close()
106
+ end
107
+ end
108
+ table.sort(dirs)
109
+ return dirs
110
+end
111
+
112
+------------------------------------------------------------------------
113
+-- Parsing helpers
114
+------------------------------------------------------------------------
115
+
116
+--- Split a string by a delimiter pattern.
117
+--- @param text string
118
+--- @param delim string
119
+--- @return string[]
120
+local function split(text, delim)
121
+ local parts = {}
122
+ local pos = 1
123
+ while true do
124
+ local s, e = text:find(delim, pos, true) -- plain find
125
+ if not s then
126
+ parts[#parts + 1] = text:sub(pos)
127
+ break
128
+ end
129
+ parts[#parts + 1] = text:sub(pos, s - 1)
130
+ pos = e + 1
131
+ end
132
+ return parts
133
+end
134
+
135
+--- Extract a field value from a markdown block by prefix.
136
+--- @param block string
137
+--- @param prefix string e.g. "**Timestamp:**"
138
+--- @return string
139
+local function extract_field(block, prefix)
140
+ -- Escape Lua pattern special chars in prefix
141
+ local escaped = prefix:gsub("([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1")
142
+ local val = block:match(escaped .. "%s*(.-)%s*\n")
143
+ if not val then
144
+ -- Try matching to end of string (last field in block)
145
+ val = block:match(escaped .. "%s*(.-)%s*$")
146
+ end
147
+ return val or ""
148
+end
149
+
150
+--- Parse commit records from commit.md content.
151
+--- @param text string
152
+--- @return CommitRecord[]
153
+local function parse_commits(text)
154
+ local records = {}
155
+ local blocks = split(text, "\n---\n")
156
+ for _, block in ipairs(blocks) do
157
+ block = block:match("^%s*(.-)%s*$") or "" -- trim
158
+ if block ~= "" then
159
+ local commit_id = block:match("## Commit `([^`]+)`") or ""
160
+ if commit_id ~= "" then
161
+ local rec = {
162
+ commit_id = commit_id,
163
+ timestamp = extract_field(block, "**Timestamp:**"),
164
+ branch_name = "",
165
+ branch_purpose = extract_field(block, "**Branch Purpose:**"),
166
+ previous_progress_summary = extract_field(block, "**Previous Progress Summary:**"),
167
+ this_commit_contribution = extract_field(block, "**This Commit's Contribution:**"),
168
+ }
169
+ records[#records + 1] = rec
170
+ end
171
+ end
172
+ end
173
+ return records
174
+end
175
+
176
+--- Parse OTA records from log.md content.
177
+--- @param text string
178
+--- @return OTARecord[]
179
+local function parse_ota(text)
180
+ local records = {}
181
+ local blocks = split(text, "\n---\n")
182
+ for _, block in ipairs(blocks) do
183
+ block = block:match("^%s*(.-)%s*$") or "" -- trim
184
+ if block ~= "" then
185
+ local step_str, ts = block:match("### Step (%d+) %— (.-)%s*\n")
186
+ if not step_str then
187
+ step_str, ts = block:match("### Step (%d+)%-(.-)%s*\n")
188
+ end
189
+ if not step_str then
190
+ step_str, ts = block:match("### Step (%d+) %— (.-)%s*$")
191
+ end
192
+ local step = tonumber(step_str) or 0
193
+ local obs = extract_field(block, "**Observation:**")
194
+ local thought = extract_field(block, "**Thought:**")
195
+ local action = extract_field(block, "**Action:**")
196
+ if obs ~= "" or thought ~= "" or action ~= "" then
197
+ records[#records + 1] = {
198
+ step = step,
199
+ timestamp = ts or "",
200
+ observation = obs,
201
+ thought = thought,
202
+ action = action,
203
+ }
204
+ end
205
+ end
206
+ end
207
+ return records
208
+end
209
+
210
+------------------------------------------------------------------------
211
+-- GCCWorkspace
212
+------------------------------------------------------------------------
213
+
214
+--- Create a new GCCWorkspace.
215
+--- @param project_root string
216
+--- @return GCCWorkspace
217
+function W.new(project_root)
218
+ local self = setmetatable({}, W)
219
+ self.root = project_root
220
+ self.gcc_dir = path_join(project_root, GCC_DIR)
221
+ self._current_branch = MAIN_BRANCH
222
+ return self
223
+end
224
+
225
+--- Initialize a fresh .GCC workspace.
226
+--- @param project_roadmap string|nil
227
+function W:init(project_roadmap)
228
+ if dir_exists(self.gcc_dir) then
229
+ error("Workspace already exists at " .. self.gcc_dir)
230
+ end
231
+
232
+ local branch_dir = path_join(self.gcc_dir, "branches", MAIN_BRANCH)
233
+ mkdir_p(branch_dir)
234
+
235
+ local ts = models.timestamp()
236
+ local roadmap = project_roadmap or ""
237
+ write_file(
238
+ path_join(self.gcc_dir, "main.md"),
239
+ string.format("# Project Roadmap\n\n**Initialized:** %s\n\n%s\n", ts, roadmap)
240
+ )
241
+
242
+ write_file(
243
+ path_join(branch_dir, "log.md"),
244
+ "# OTA Log — branch `main`\n\n"
245
+ )
246
+ write_file(
247
+ path_join(branch_dir, "commit.md"),
248
+ "# Commit History — branch `main`\n\n"
249
+ )
250
+
251
+ local meta = models.new_branch_metadata(MAIN_BRANCH, "Primary reasoning trajectory", "", ts)
252
+ write_file(
253
+ path_join(branch_dir, "metadata.yaml"),
254
+ models.metadata_to_yaml(meta)
255
+ )
256
+
257
+ self._current_branch = MAIN_BRANCH
258
+end
259
+
260
+--- Attach to an existing .GCC workspace.
261
+function W:load()
262
+ if not dir_exists(self.gcc_dir) then
263
+ error("No workspace found at " .. self.gcc_dir)
264
+ end
265
+ self._current_branch = MAIN_BRANCH
266
+end
267
+
268
+--- Get current branch name.
269
+--- @return string
270
+function W:current_branch()
271
+ return self._current_branch
272
+end
273
+
274
+--- Get the directory path for a branch.
275
+--- @param name string|nil
276
+--- @return string
277
+function W:_branch_dir(name)
278
+ return path_join(self.gcc_dir, "branches", name or self._current_branch)
279
+end
280
+
281
+--- Log an OTA cycle to the current branch.
282
+--- @param observation string
283
+--- @param thought string
284
+--- @param action string
285
+--- @return OTARecord
286
+function W:log_ota(observation, thought, action)
287
+ local branch_dir = self:_branch_dir()
288
+ local log_path = path_join(branch_dir, "log.md")
289
+ local existing = parse_ota(read_file(log_path))
290
+ local step = #existing + 1
291
+ local rec = models.new_ota(step, observation, thought, action)
292
+ append_file(log_path, models.ota_to_markdown(rec))
293
+ return rec
294
+end
295
+
296
+--- COMMIT — checkpoint a milestone on the current branch.
297
+--- @param contribution string
298
+--- @param previous_summary string|nil
299
+--- @param update_roadmap string|nil
300
+--- @return CommitRecord
301
+function W:commit(contribution, previous_summary, update_roadmap)
302
+ local branch_dir = self:_branch_dir()
303
+ local meta_text = read_file(path_join(branch_dir, "metadata.yaml"))
304
+ local meta = models.metadata_from_yaml(meta_text)
305
+ local branch_purpose = meta.purpose or ""
306
+
307
+ if not previous_summary or previous_summary == "" then
308
+ local commits = parse_commits(read_file(path_join(branch_dir, "commit.md")))
309
+ if #commits > 0 then
310
+ previous_summary = commits[#commits].this_commit_contribution
311
+ else
312
+ previous_summary = "Initial state — no prior commits."
313
+ end
314
+ end
315
+
316
+ local rec = models.new_commit(
317
+ self._current_branch, branch_purpose,
318
+ previous_summary, contribution
319
+ )
320
+ append_file(
321
+ path_join(branch_dir, "commit.md"),
322
+ models.commit_to_markdown(rec)
323
+ )
324
+
325
+ if update_roadmap and update_roadmap ~= "" then
326
+ local ts = models.timestamp()
327
+ append_file(
328
+ path_join(self.gcc_dir, "main.md"),
329
+ string.format("\n## Update (%s)\n%s\n", ts, update_roadmap)
330
+ )
331
+ end
332
+
333
+ return rec
334
+end
335
+
336
+--- BRANCH — create an isolated reasoning workspace.
337
+--- @param name string
338
+--- @param purpose string
339
+function W:branch(name, purpose)
340
+ local branch_dir = path_join(self.gcc_dir, "branches", name)
341
+ if dir_exists(branch_dir) then
342
+ error("Branch already exists: " .. name)
343
+ end
344
+
345
+ mkdir_p(branch_dir)
346
+
347
+ write_file(
348
+ path_join(branch_dir, "log.md"),
349
+ string.format("# OTA Log — branch `%s`\n\n", name)
350
+ )
351
+ write_file(
352
+ path_join(branch_dir, "commit.md"),
353
+ string.format("# Commit History — branch `%s`\n\n", name)
354
+ )
355
+
356
+ local meta = models.new_branch_metadata(name, purpose, self._current_branch)
357
+ write_file(
358
+ path_join(branch_dir, "metadata.yaml"),
359
+ models.metadata_to_yaml(meta)
360
+ )
361
+
362
+ self._current_branch = name
363
+end
364
+
365
+--- Switch to an existing branch.
366
+--- @param name string
367
+function W:switch_branch(name)
368
+ local branch_dir = path_join(self.gcc_dir, "branches", name)
369
+ if not dir_exists(branch_dir) then
370
+ error("Branch does not exist: " .. name)
371
+ end
372
+ self._current_branch = name
373
+end
374
+
375
+--- List all branches.
376
+--- @return string[]
377
+function W:list_branches()
378
+ local branches_dir = path_join(self.gcc_dir, "branches")
379
+ if not dir_exists(branches_dir) then return {} end
380
+ return list_dirs(branches_dir)
381
+end
382
+
383
+--- MERGE — integrate a branch back into a target.
384
+--- @param branch_name string
385
+--- @param summary string|nil
386
+--- @param target string|nil
387
+--- @return CommitRecord
388
+function W:merge(branch_name, summary, target)
389
+ target = target or MAIN_BRANCH
390
+
391
+ local src_dir = path_join(self.gcc_dir, "branches", branch_name)
392
+ local tgt_dir = path_join(self.gcc_dir, "branches", target)
393
+
394
+ if not dir_exists(src_dir) then
395
+ error("Source branch does not exist: " .. branch_name)
396
+ end
397
+ if not dir_exists(tgt_dir) then
398
+ error("Target branch does not exist: " .. target)
399
+ end
400
+
401
+ local src_commits = parse_commits(read_file(path_join(src_dir, "commit.md")))
402
+ local src_ota = parse_ota(read_file(path_join(src_dir, "log.md")))
403
+ local src_meta_text = read_file(path_join(src_dir, "metadata.yaml"))
404
+ local src_meta = models.metadata_from_yaml(src_meta_text)
405
+
406
+ -- Auto-generate summary if not provided
407
+ if not summary or summary == "" then
408
+ local contributions = {}
409
+ for _, c in ipairs(src_commits) do
410
+ contributions[#contributions + 1] = c.this_commit_contribution
411
+ end
412
+ summary = string.format(
413
+ "Merged branch `%s` (%d commits). Contributions: %s",
414
+ branch_name, #src_commits, table.concat(contributions, " | ")
415
+ )
416
+ end
417
+
418
+ -- Append OTA records from source to target
419
+ if #src_ota > 0 then
420
+ local ts = models.timestamp()
421
+ local header = string.format("\n## Merged from `%s` (%s)\n\n", branch_name, ts)
422
+ append_file(path_join(tgt_dir, "log.md"), header)
423
+ for _, rec in ipairs(src_ota) do
424
+ append_file(path_join(tgt_dir, "log.md"), models.ota_to_markdown(rec))
425
+ end
426
+ end
427
+
428
+ -- Switch to target and create merge commit
429
+ self._current_branch = target
430
+ local prev = string.format("Merging branch `%s` with purpose: %s", branch_name, src_meta.purpose or "")
431
+ local roadmap_update = string.format("Merged `%s`: %s", branch_name, summary)
432
+ local commit_rec = self:commit(summary, prev, roadmap_update)
433
+
434
+ -- Update source metadata
435
+ local ts = models.timestamp()
436
+ src_meta.status = "merged"
437
+ src_meta.merged_into = target
438
+ src_meta.merged_at = ts
439
+ write_file(path_join(src_dir, "metadata.yaml"), models.metadata_to_yaml(src_meta))
440
+
441
+ return commit_rec
442
+end
443
+
444
+--- CONTEXT — hierarchical memory retrieval.
445
+--- @param branch string|nil
446
+--- @param k number|nil
447
+--- @return ContextResult
448
+function W:context(branch, k)
449
+ branch = branch or self._current_branch
450
+ k = k or 1
451
+
452
+ local branch_dir = path_join(self.gcc_dir, "branches", branch)
453
+ if not dir_exists(branch_dir) then
454
+ error("Branch does not exist: " .. branch)
455
+ end
456
+
457
+ local all_commits = parse_commits(read_file(path_join(branch_dir, "commit.md")))
458
+ local all_ota = parse_ota(read_file(path_join(branch_dir, "log.md")))
459
+ local roadmap = read_file(path_join(self.gcc_dir, "main.md"))
460
+ local meta_text = read_file(path_join(branch_dir, "metadata.yaml"))
461
+ local meta = nil
462
+ if meta_text ~= "" then
463
+ meta = models.metadata_from_yaml(meta_text)
464
+ end
465
+
466
+ -- Last k commits
467
+ local commits = {}
468
+ local start = math.max(1, #all_commits - k + 1)
469
+ for i = start, #all_commits do
470
+ commits[#commits + 1] = all_commits[i]
471
+ end
472
+
473
+ local result = models.new_context_result(branch, k)
474
+ result.commits = commits
475
+ result.ota_records = all_ota
476
+ result.main_roadmap = roadmap
477
+ result.metadata = meta
478
+
479
+ return result
480
+end
481
+
482
+return W
0
483