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
| @@ -0,0 +1,482 @@ | ||
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 | |