refactor(zig): rename module from 'contexa' to 'cortexa'
Swadhin Biswas committed 7 months ago
0419a09
1 parent 76bac60
commit 0419a09
4 files changed
+523 -48
Showing 4 changed files+523-48
▼ZIG/README.md
+10-10
| @@ -1,11 +1,11 @@ | ||
1 | -# contexa | |
1 | +# cortexa | |
2 | 2 | |
3 | 3 | [](LICENSE) |
4 | -[](https://github.com/swadhinbiswas/contexa) | |
4 | +[](https://github.com/swadhinbiswas/Cortexa) | |
5 | 5 | |
6 | 6 | **Git-inspired context management for LLM agents.** COMMIT, BRANCH, MERGE, and CONTEXT operations over a persistent versioned memory workspace. |
7 | 7 | |
8 | -Zig implementation of the **contexa** framework. | |
8 | +Zig implementation of the **cortexa** framework. | |
9 | 9 | |
10 | 10 | Based on: [arXiv:2508.00031](https://arxiv.org/abs/2508.00031) -- *"Git Context Controller: Manage the Context of LLM-based Agents like Git"* (Junde Wu et al., 2025) |
11 | 11 | |
| @@ -19,8 +19,8 @@ Add to your `build.zig.zon`: | ||
19 | 19 | |
20 | 20 | ```zig |
21 | 21 | .dependencies = .{ |
22 | - .contexa = .{ | |
23 | - .url = "https://github.com/swadhinbiswas/contexa/archive/refs/tags/v0.1.1.tar.gz", | |
22 | + .cortexa = .{ | |
23 | + .url = "https://github.com/swadhinbiswas/Cortexa/archive/refs/tags/v0.1.1.tar.gz", | |
24 | 24 | .hash = "...", // zig build will tell you the correct hash |
25 | 25 | }, |
26 | 26 | }, |
| @@ -29,11 +29,11 @@ Add to your `build.zig.zon`: | ||
29 | 29 | Then in your `build.zig`: |
30 | 30 | |
31 | 31 | ```zig |
32 | -const contexa = b.dependency("contexa", .{ | |
32 | +const cortexa = b.dependency("cortexa", .{ | |
33 | 33 | .target = target, |
34 | 34 | .optimize = optimize, |
35 | 35 | }); |
36 | -exe.root_module.addImport("contexa", contexa.module("contexa")); | |
36 | +exe.root_module.addImport("cortexa", cortexa.module("cortexa")); | |
37 | 37 | ``` |
38 | 38 | |
39 | 39 | --- |
| @@ -42,7 +42,7 @@ exe.root_module.addImport("contexa", contexa.module("contexa")); | ||
42 | 42 | |
43 | 43 | ```zig |
44 | 44 | const std = @import("std"); |
45 | -const contexa = @import("contexa"); | |
45 | +const cortexa = @import("cortexa"); | |
46 | 46 | |
47 | 47 | pub fn main() !void { |
48 | 48 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; |
| @@ -50,7 +50,7 @@ pub fn main() !void { | ||
50 | 50 | const allocator = gpa.allocator(); |
51 | 51 | |
52 | 52 | // 1. Initialize a workspace |
53 | - var ws = contexa.Workspace.init(allocator, "/path/to/project"); | |
53 | + var ws = cortexa.Workspace.init(allocator, "/path/to/project"); | |
54 | 54 | try ws.create("Build a REST API service with user auth"); |
55 | 55 | |
56 | 56 | // 2. Agent logs its reasoning as it works |
| @@ -186,6 +186,6 @@ MIT License. See [LICENSE](LICENSE) for details. | ||
186 | 186 | |
187 | 187 | ## Links |
188 | 188 | |
189 | -- [GitHub Repository](https://github.com/swadhinbiswas/contexa) | |
189 | +- [GitHub Repository](https://github.com/swadhinbiswas/Cortexa) | |
190 | 190 | - [Original Paper](https://arxiv.org/abs/2508.00031) |
191 | 191 | - [Author: Swadhin Biswas](https://github.com/swadhinbiswas) |
192 | 192 | |
▼ZIG/build.zig
+2-2
| @@ -5,7 +5,7 @@ pub fn build(b: *std.Build) void { | ||
5 | 5 | const optimize = b.standardOptimizeOption(.{}); |
6 | 6 | |
7 | 7 | // Module (for use as a dependency by consumers) |
8 | - const mod = b.addModule("contexa", .{ | |
8 | + const mod = b.addModule("cortexa", .{ | |
9 | 9 | .root_source_file = b.path("src/main.zig"), |
10 | 10 | .target = target, |
11 | 11 | .optimize = optimize, |
| @@ -13,7 +13,7 @@ pub fn build(b: *std.Build) void { | ||
13 | 13 | |
14 | 14 | // Static library artifact |
15 | 15 | const lib = b.addLibrary(.{ |
16 | - .name = "contexa", | |
16 | + .name = "cortexa", | |
17 | 17 | .root_module = mod, |
18 | 18 | }); |
19 | 19 | b.installArtifact(lib); |
20 | 20 | |
▼ZIG/build.zig.zon
+2-2
| @@ -1,7 +1,7 @@ | ||
1 | 1 | .{ |
2 | - .name = .contexa, | |
2 | + .name = .cortexa, | |
3 | 3 | .version = "0.1.1", |
4 | - .fingerprint = 0x8f806195fce1319f, | |
4 | + .fingerprint = 0x2a901b166db9b79c, | |
5 | 5 | .minimum_zig_version = "0.14.0", |
6 | 6 | .dependencies = .{}, |
7 | 7 | .paths = .{ |
8 | 8 | |
▼ZIG/src/main.zig
+509-34
| @@ -1,4 +1,4 @@ | ||
1 | -//! contexa — Git-inspired context management for LLM agents. | |
1 | +//! cortexa — Git-inspired context management for LLM agents. | |
2 | 2 | //! COMMIT, BRANCH, MERGE, and CONTEXT over versioned memory. |
3 | 3 | //! |
4 | 4 | //! Paper: "Git Context Controller: Manage the Context of LLM-based Agents like Git" |
| @@ -23,6 +23,40 @@ const fmt = std.fmt; | ||
23 | 23 | pub const MAIN_BRANCH = "main"; |
24 | 24 | pub const GCC_DIR = ".GCC"; |
25 | 25 | |
26 | +// ------------------------------------------------------------------ // | |
27 | +// Input Validation // | |
28 | +// ------------------------------------------------------------------ // | |
29 | + | |
30 | +pub const ValidationError = error{ | |
31 | + EmptyInput, | |
32 | + InvalidBranchName, | |
33 | +}; | |
34 | + | |
35 | +/// Returns true if value is empty or whitespace-only. | |
36 | +fn isEmpty(value: []const u8) bool { | |
37 | + if (value.len == 0) return true; | |
38 | + for (value) |c| { | |
39 | + if (c != ' ' and c != '\t' and c != '\n' and c != '\r') return false; | |
40 | + } | |
41 | + return true; | |
42 | +} | |
43 | + | |
44 | +/// Return error if value is empty or whitespace-only. | |
45 | +fn validateNotEmpty(value: []const u8) ValidationError!void { | |
46 | + if (isEmpty(value)) return ValidationError.EmptyInput; | |
47 | +} | |
48 | + | |
49 | +/// Return error if name is not a valid branch identifier. | |
50 | +fn validateBranchName(name: []const u8) ValidationError!void { | |
51 | + try validateNotEmpty(name); | |
52 | + if (mem.indexOf(u8, name, "/") != null or mem.indexOf(u8, name, "\\") != null) { | |
53 | + return ValidationError.InvalidBranchName; | |
54 | + } | |
55 | + if (mem.eql(u8, name, ".") or mem.eql(u8, name, "..")) { | |
56 | + return ValidationError.InvalidBranchName; | |
57 | + } | |
58 | +} | |
59 | + | |
26 | 60 | // ------------------------------------------------------------------ // |
27 | 61 | // Data Models // |
28 | 62 | // ------------------------------------------------------------------ // |
| @@ -97,6 +131,27 @@ pub const BranchMetadata = struct { | ||
97 | 131 | } |
98 | 132 | }; |
99 | 133 | |
134 | +/// Parsed branch metadata from YAML. Owns all string memory. | |
135 | +const ParsedBranchMetadata = struct { | |
136 | + name: []u8, | |
137 | + purpose: []u8, | |
138 | + created_from: []u8, | |
139 | + created_at: []u8, | |
140 | + status: []u8, | |
141 | + merged_into: ?[]u8 = null, | |
142 | + merged_at: ?[]u8 = null, | |
143 | + | |
144 | + pub fn deinit(self: *const ParsedBranchMetadata, allocator: mem.Allocator) void { | |
145 | + allocator.free(self.name); | |
146 | + allocator.free(self.purpose); | |
147 | + allocator.free(self.created_from); | |
148 | + allocator.free(self.created_at); | |
149 | + allocator.free(self.status); | |
150 | + if (self.merged_into) |mi| allocator.free(mi); | |
151 | + if (self.merged_at) |ma| allocator.free(ma); | |
152 | + } | |
153 | +}; | |
154 | + | |
100 | 155 | // ------------------------------------------------------------------ // |
101 | 156 | // Workspace // |
102 | 157 | // ------------------------------------------------------------------ // |
| @@ -118,6 +173,32 @@ pub const Workspace = struct { | ||
118 | 173 | }; |
119 | 174 | } |
120 | 175 | |
176 | + // ---------------------------------------------------------------- // | |
177 | + // File locking // | |
178 | + // ---------------------------------------------------------------- // | |
179 | + | |
180 | + /// Acquire an exclusive file lock on .GCC/.lock. | |
181 | + /// Returns the lock file handle; caller must call unlock() + close() when done. | |
182 | + fn acquireLock(self: Self) !fs.File { | |
183 | + const lock_path = try fs.path.join(self.allocator, &.{ self.root, GCC_DIR, ".lock" }); | |
184 | + defer self.allocator.free(lock_path); | |
185 | + const file = fs.createFileAbsolute(lock_path, .{ .truncate = false }) catch |err| { | |
186 | + if (err == error.PathAlreadyExists) { | |
187 | + const f = try fs.openFileAbsolute(lock_path, .{ .mode = .write_only }); | |
188 | + try f.lock(.exclusive); | |
189 | + return f; | |
190 | + } | |
191 | + return err; | |
192 | + }; | |
193 | + try file.lock(.exclusive); | |
194 | + return file; | |
195 | + } | |
196 | + | |
197 | + fn releaseLock(_: Self, file: fs.File) void { | |
198 | + file.unlock(); | |
199 | + file.close(); | |
200 | + } | |
201 | + | |
121 | 202 | // ---------------------------------------------------------------- // |
122 | 203 | // Path helpers // |
123 | 204 | // ---------------------------------------------------------------- // |
| @@ -195,9 +276,24 @@ pub const Workspace = struct { | ||
195 | 276 | return count; |
196 | 277 | } |
197 | 278 | |
198 | - fn nowTimestamp(_: Self) []const u8 { | |
199 | - // Returns a static placeholder; in production use std.time | |
200 | - return "2025-01-01T00:00:00Z"; | |
279 | + /// Returns an allocated ISO 8601 UTC timestamp string. | |
280 | + /// The caller owns the returned memory. | |
281 | + fn nowTimestamp(self: Self) ![]u8 { | |
282 | + const epoch_seconds = std.time.timestamp(); | |
283 | + const es = std.time.epoch.EpochSeconds{ .secs = @intCast(epoch_seconds) }; | |
284 | + const epoch_day = es.getEpochDay(); | |
285 | + const year_day = epoch_day.calculateYearDay(); | |
286 | + const month_day = year_day.calculateMonthDay(); | |
287 | + const day_seconds = es.getDaySeconds(); | |
288 | + | |
289 | + return fmt.allocPrint(self.allocator, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ | |
290 | + year_day.year, | |
291 | + @intFromEnum(month_day.month), | |
292 | + month_day.day_index + 1, | |
293 | + day_seconds.getHoursIntoDay(), | |
294 | + day_seconds.getMinutesIntoHour(), | |
295 | + day_seconds.getSecondsIntoMinute(), | |
296 | + }); | |
201 | 297 | } |
202 | 298 | |
203 | 299 | fn generateID(self: Self) ![]u8 { |
| @@ -207,6 +303,60 @@ pub const Workspace = struct { | ||
207 | 303 | return id; |
208 | 304 | } |
209 | 305 | |
306 | + /// Parse a YAML value from a line of text. | |
307 | + /// Returns the value after "key: " with surrounding quotes stripped. | |
308 | + fn parseYamlValue(self: Self, content: []const u8, key: []const u8) ![]u8 { | |
309 | + const search = try fmt.allocPrint(self.allocator, "{s}: ", .{key}); | |
310 | + defer self.allocator.free(search); | |
311 | + | |
312 | + var lines_it = mem.splitScalar(u8, content, '\n'); | |
313 | + while (lines_it.next()) |line| { | |
314 | + if (mem.startsWith(u8, line, search)) { | |
315 | + var val = line[search.len..]; | |
316 | + // Strip surrounding single quotes | |
317 | + if (val.len >= 2 and val[0] == '\'' and val[val.len - 1] == '\'') { | |
318 | + val = val[1 .. val.len - 1]; | |
319 | + } | |
320 | + if (mem.eql(u8, val, "null")) { | |
321 | + return try self.allocator.dupe(u8, ""); | |
322 | + } | |
323 | + return try self.allocator.dupe(u8, val); | |
324 | + } | |
325 | + } | |
326 | + return try self.allocator.dupe(u8, ""); | |
327 | + } | |
328 | + | |
329 | + /// Parse branch metadata from YAML content. | |
330 | + fn parseBranchMetadata(self: Self, content: []const u8) !ParsedBranchMetadata { | |
331 | + const name = try self.parseYamlValue(content, "name"); | |
332 | + const purpose = try self.parseYamlValue(content, "purpose"); | |
333 | + const created_from = try self.parseYamlValue(content, "created_from"); | |
334 | + const created_at = try self.parseYamlValue(content, "created_at"); | |
335 | + const status = try self.parseYamlValue(content, "status"); | |
336 | + const mi_str = try self.parseYamlValue(content, "merged_into"); | |
337 | + const ma_str = try self.parseYamlValue(content, "merged_at"); | |
338 | + | |
339 | + const merged_into: ?[]u8 = if (mi_str.len == 0) blk: { | |
340 | + self.allocator.free(mi_str); | |
341 | + break :blk null; | |
342 | + } else mi_str; | |
343 | + | |
344 | + const merged_at: ?[]u8 = if (ma_str.len == 0) blk: { | |
345 | + self.allocator.free(ma_str); | |
346 | + break :blk null; | |
347 | + } else ma_str; | |
348 | + | |
349 | + return .{ | |
350 | + .name = name, | |
351 | + .purpose = purpose, | |
352 | + .created_from = created_from, | |
353 | + .created_at = created_at, | |
354 | + .status = status, | |
355 | + .merged_into = merged_into, | |
356 | + .merged_at = merged_at, | |
357 | + }; | |
358 | + } | |
359 | + | |
210 | 360 | // ---------------------------------------------------------------- // |
211 | 361 | // GCC Workspace Initialisation // |
212 | 362 | // ---------------------------------------------------------------- // |
| @@ -226,13 +376,18 @@ pub const Workspace = struct { | ||
226 | 376 | try fs.makeDirAbsolute(branches_path); |
227 | 377 | try fs.makeDirAbsolute(branch_dir); |
228 | 378 | |
379 | + const lock_file = try self.acquireLock(); | |
380 | + defer self.releaseLock(lock_file); | |
381 | + | |
229 | 382 | // main.md — global roadmap |
230 | 383 | const main_path = try self.mainMdPath(); |
231 | 384 | defer self.allocator.free(main_path); |
385 | + const ts = try self.nowTimestamp(); | |
386 | + defer self.allocator.free(ts); | |
232 | 387 | const roadmap = try fmt.allocPrint( |
233 | 388 | self.allocator, |
234 | 389 | "# Project Roadmap\n\n**Initialized:** {s}\n\n{s}\n", |
235 | - .{ self.nowTimestamp(), project_roadmap }, | |
390 | + .{ ts, project_roadmap }, | |
236 | 391 | ); |
237 | 392 | defer self.allocator.free(roadmap); |
238 | 393 | try self.writeFile(main_path, roadmap); |
| @@ -250,13 +405,15 @@ pub const Workspace = struct { | ||
250 | 405 | // metadata.yaml |
251 | 406 | const meta_file = try self.metaFilePath(MAIN_BRANCH); |
252 | 407 | defer self.allocator.free(meta_file); |
408 | + const meta_ts = try self.nowTimestamp(); | |
409 | + defer self.allocator.free(meta_ts); | |
253 | 410 | var buf: std.ArrayListUnmanaged(u8) = .empty; |
254 | 411 | defer buf.deinit(self.allocator); |
255 | 412 | const meta = BranchMetadata{ |
256 | 413 | .name = MAIN_BRANCH, |
257 | 414 | .purpose = "Primary reasoning trajectory", |
258 | 415 | .created_from = "", |
259 | - .created_at = self.nowTimestamp(), | |
416 | + .created_at = meta_ts, | |
260 | 417 | .status = "active", |
261 | 418 | }; |
262 | 419 | try meta.writeYaml(buf.writer(self.allocator)); |
| @@ -277,10 +434,19 @@ pub const Workspace = struct { | ||
277 | 434 | thought: []const u8, |
278 | 435 | action: []const u8, |
279 | 436 | ) !OTARecord { |
437 | + // At least one field must be non-empty | |
438 | + if (isEmpty(observation) and isEmpty(thought) and isEmpty(action)) | |
439 | + return ValidationError.EmptyInput; | |
440 | + | |
441 | + const lock_file = try self.acquireLock(); | |
442 | + defer self.releaseLock(lock_file); | |
443 | + | |
280 | 444 | const step = (try self.countOTASteps(self.current_branch)) + 1; |
445 | + const ts = try self.nowTimestamp(); | |
446 | + defer self.allocator.free(ts); | |
281 | 447 | const record = OTARecord{ |
282 | 448 | .step = step, |
283 | - .timestamp = self.nowTimestamp(), | |
449 | + .timestamp = ts, | |
284 | 450 | .observation = observation, |
285 | 451 | .thought = thought, |
286 | 452 | .action = action, |
| @@ -302,18 +468,46 @@ pub const Workspace = struct { | ||
302 | 468 | self: *Self, |
303 | 469 | contribution: []const u8, |
304 | 470 | previous_summary: ?[]const u8, |
471 | + ) !CommitRecord { | |
472 | + try validateNotEmpty(contribution); | |
473 | + | |
474 | + const lock_file = try self.acquireLock(); | |
475 | + defer self.releaseLock(lock_file); | |
476 | + return self.commitInner(contribution, previous_summary); | |
477 | + } | |
478 | + | |
479 | + /// Internal commit logic, called with lock already held. | |
480 | + fn commitInner( | |
481 | + self: *Self, | |
482 | + contribution: []const u8, | |
483 | + previous_summary: ?[]const u8, | |
305 | 484 | ) !CommitRecord { |
306 | 485 | const id = try self.generateID(); |
307 | 486 | |
487 | + // Read branch purpose from metadata | |
488 | + const meta_file = try self.metaFilePath(self.current_branch); | |
489 | + defer self.allocator.free(meta_file); | |
490 | + const meta_content = try self.readFile(meta_file); | |
491 | + defer self.allocator.free(meta_content); | |
492 | + var branch_purpose: []const u8 = "Active branch"; | |
493 | + var parsed_meta: ?ParsedBranchMetadata = null; | |
494 | + if (meta_content.len > 0) { | |
495 | + parsed_meta = try self.parseBranchMetadata(meta_content); | |
496 | + branch_purpose = parsed_meta.?.purpose; | |
497 | + } | |
498 | + defer if (parsed_meta) |pm| pm.deinit(self.allocator); | |
499 | + | |
308 | 500 | const prev = previous_summary orelse "Initial state — no prior commits."; |
309 | 501 | |
502 | + const ts = try self.nowTimestamp(); | |
503 | + defer self.allocator.free(ts); | |
310 | 504 | const record = CommitRecord{ |
311 | 505 | .commit_id = id, |
312 | 506 | .branch_name = self.current_branch, |
313 | - .branch_purpose = "Active branch", | |
507 | + .branch_purpose = branch_purpose, | |
314 | 508 | .previous_progress_summary = prev, |
315 | 509 | .this_commit_contribution = contribution, |
316 | - .timestamp = self.nowTimestamp(), | |
510 | + .timestamp = ts, | |
317 | 511 | }; |
318 | 512 | |
319 | 513 | const commit_file = try self.commitFilePath(self.current_branch); |
| @@ -324,24 +518,30 @@ pub const Workspace = struct { | ||
324 | 518 | try record.writeMarkdown(buf.writer(self.allocator)); |
325 | 519 | try self.appendToFile(commit_file, buf.items); |
326 | 520 | |
327 | - // Return record (commit_id is already owned by caller via generateID) | |
521 | + // Return record — commit_id ownership transfers to caller | |
328 | 522 | return CommitRecord{ |
329 | 523 | .commit_id = id, |
330 | 524 | .branch_name = self.current_branch, |
331 | - .branch_purpose = "Active branch", | |
525 | + .branch_purpose = branch_purpose, | |
332 | 526 | .previous_progress_summary = prev, |
333 | 527 | .this_commit_contribution = contribution, |
334 | - .timestamp = self.nowTimestamp(), | |
528 | + .timestamp = ts, | |
335 | 529 | }; |
336 | 530 | } |
337 | 531 | |
338 | 532 | /// BRANCH command (paper §3.3). |
339 | 533 | /// Creates isolated workspace: B_t^(name) = BRANCH(M_{t-1}). |
340 | 534 | pub fn branch(self: *Self, name: []const u8, purpose: []const u8) !void { |
535 | + try validateBranchName(name); | |
536 | + try validateNotEmpty(purpose); | |
537 | + | |
341 | 538 | const branch_dir = try self.branchDir(name); |
342 | 539 | defer self.allocator.free(branch_dir); |
343 | 540 | try fs.makeDirAbsolute(branch_dir); |
344 | 541 | |
542 | + const lock_file = try self.acquireLock(); | |
543 | + defer self.releaseLock(lock_file); | |
544 | + | |
345 | 545 | // Empty OTA log (fresh execution trace, per paper §3.3) |
346 | 546 | const log = try self.logPath(name); |
347 | 547 | defer self.allocator.free(log); |
| @@ -367,13 +567,15 @@ pub const Workspace = struct { | ||
367 | 567 | // metadata.yaml records intent and motivation (paper §3.3) |
368 | 568 | const meta_file = try self.metaFilePath(name); |
369 | 569 | defer self.allocator.free(meta_file); |
570 | + const ts = try self.nowTimestamp(); | |
571 | + defer self.allocator.free(ts); | |
370 | 572 | var buf: std.ArrayListUnmanaged(u8) = .empty; |
371 | 573 | defer buf.deinit(self.allocator); |
372 | 574 | const meta = BranchMetadata{ |
373 | 575 | .name = name, |
374 | 576 | .purpose = purpose, |
375 | 577 | .created_from = self.current_branch, |
376 | - .created_at = self.nowTimestamp(), | |
578 | + .created_at = ts, | |
377 | 579 | .status = "active", |
378 | 580 | }; |
379 | 581 | try meta.writeYaml(buf.writer(self.allocator)); |
| @@ -384,7 +586,26 @@ pub const Workspace = struct { | ||
384 | 586 | |
385 | 587 | /// MERGE command (paper §3.4). |
386 | 588 | /// Integrates branch into target, merging summaries and OTA traces. |
589 | + /// Preserves the original branch metadata (purpose, created_from, created_at) | |
590 | + /// and only updates status, merged_into, and merged_at fields. | |
387 | 591 | pub fn merge(self: *Self, branch_name: []const u8, target: []const u8) !CommitRecord { |
592 | + try validateBranchName(branch_name); | |
593 | + try validateBranchName(target); | |
594 | + | |
595 | + // Read original branch metadata before anything else | |
596 | + const orig_meta_file = try self.metaFilePath(branch_name); | |
597 | + defer self.allocator.free(orig_meta_file); | |
598 | + const orig_meta_content = try self.readFile(orig_meta_file); | |
599 | + defer self.allocator.free(orig_meta_content); | |
600 | + var parsed_meta: ?ParsedBranchMetadata = null; | |
601 | + if (orig_meta_content.len > 0) { | |
602 | + parsed_meta = try self.parseBranchMetadata(orig_meta_content); | |
603 | + } | |
604 | + defer if (parsed_meta) |pm| pm.deinit(self.allocator); | |
605 | + | |
606 | + const lock_file = try self.acquireLock(); | |
607 | + defer self.releaseLock(lock_file); | |
608 | + | |
388 | 609 | // Append branch OTA to target's log |
389 | 610 | const src_log = try self.logPath(branch_name); |
390 | 611 | defer self.allocator.free(src_log); |
| @@ -394,10 +615,12 @@ pub const Workspace = struct { | ||
394 | 615 | if (src_content.len > 0) { |
395 | 616 | const target_log = try self.logPath(target); |
396 | 617 | defer self.allocator.free(target_log); |
618 | + const merge_ts = try self.nowTimestamp(); | |
619 | + defer self.allocator.free(merge_ts); | |
397 | 620 | const header = try fmt.allocPrint( |
398 | 621 | self.allocator, |
399 | 622 | "\n## Merged from `{s}` ({s})\n\n", |
400 | - .{ branch_name, self.nowTimestamp() }, | |
623 | + .{ branch_name, merge_ts }, | |
401 | 624 | ); |
402 | 625 | defer self.allocator.free(header); |
403 | 626 | try self.appendToFile(target_log, header); |
| @@ -411,25 +634,41 @@ pub const Workspace = struct { | ||
411 | 634 | "Merged branch `{s}` into `{s}`", |
412 | 635 | .{ branch_name, target }, |
413 | 636 | ); |
414 | - // Note: summary is owned by allocator; commit will reference it, then we free | |
415 | - const merge_commit = try self.commit(summary, null); | |
416 | - | |
417 | - // Update branch metadata to mark as merged | |
418 | - const meta_file = try self.metaFilePath(branch_name); | |
419 | - defer self.allocator.free(meta_file); | |
637 | + defer self.allocator.free(summary); | |
638 | + const merge_commit = try self.commitInner(summary, null); | |
639 | + | |
640 | + // Update branch metadata to mark as merged, preserving original fields | |
641 | + const meta_file2 = try self.metaFilePath(branch_name); | |
642 | + defer self.allocator.free(meta_file2); | |
643 | + const merged_ts = try self.nowTimestamp(); | |
644 | + defer self.allocator.free(merged_ts); | |
420 | 645 | var buf: std.ArrayListUnmanaged(u8) = .empty; |
421 | 646 | defer buf.deinit(self.allocator); |
422 | - const meta = BranchMetadata{ | |
423 | - .name = branch_name, | |
424 | - .purpose = "Merged branch", | |
425 | - .created_from = "", | |
426 | - .created_at = self.nowTimestamp(), | |
427 | - .status = "merged", | |
428 | - .merged_into = target, | |
429 | - .merged_at = self.nowTimestamp(), | |
430 | - }; | |
431 | - try meta.writeYaml(buf.writer(self.allocator)); | |
432 | - try self.writeFile(meta_file, buf.items); | |
647 | + | |
648 | + if (parsed_meta) |pm| { | |
649 | + const updated_meta = BranchMetadata{ | |
650 | + .name = pm.name, | |
651 | + .purpose = pm.purpose, | |
652 | + .created_from = pm.created_from, | |
653 | + .created_at = pm.created_at, | |
654 | + .status = "merged", | |
655 | + .merged_into = target, | |
656 | + .merged_at = merged_ts, | |
657 | + }; | |
658 | + try updated_meta.writeYaml(buf.writer(self.allocator)); | |
659 | + } else { | |
660 | + const fallback_meta = BranchMetadata{ | |
661 | + .name = branch_name, | |
662 | + .purpose = "", | |
663 | + .created_from = "", | |
664 | + .created_at = "", | |
665 | + .status = "merged", | |
666 | + .merged_into = target, | |
667 | + .merged_at = merged_ts, | |
668 | + }; | |
669 | + try fallback_meta.writeYaml(buf.writer(self.allocator)); | |
670 | + } | |
671 | + try self.writeFile(meta_file2, buf.items); | |
433 | 672 | |
434 | 673 | return merge_commit; |
435 | 674 | } |
| @@ -437,9 +676,15 @@ pub const Workspace = struct { | ||
437 | 676 | /// CONTEXT command (paper §3.5). |
438 | 677 | /// Reads the K most-recent commits + OTA log from the given branch. |
439 | 678 | /// Paper experiments fix K=1 (most recent commit record only). |
679 | + /// Returns a ContextSnapshot with parsed commits filtered to last K. | |
440 | 680 | pub fn context(self: Self, branch_name: ?[]const u8, k: usize) !ContextSnapshot { |
681 | + if (k < 1) return ValidationError.EmptyInput; | |
682 | + | |
441 | 683 | const target = branch_name orelse self.current_branch; |
442 | 684 | |
685 | + const lock_file = try self.acquireLock(); | |
686 | + defer self.releaseLock(lock_file); | |
687 | + | |
443 | 688 | const main_path = try self.mainMdPath(); |
444 | 689 | defer self.allocator.free(main_path); |
445 | 690 | const roadmap = try self.readFile(main_path); |
| @@ -450,22 +695,69 @@ pub const Workspace = struct { | ||
450 | 695 | |
451 | 696 | const commit_file = try self.commitFilePath(target); |
452 | 697 | defer self.allocator.free(commit_file); |
453 | - const commit_content = try self.readFile(commit_file); | |
698 | + const all_commit_content = try self.readFile(commit_file); | |
699 | + defer self.allocator.free(all_commit_content); | |
700 | + | |
701 | + // Parse and filter to last K commits for the returned content | |
702 | + var commit_blocks: std.ArrayListUnmanaged([]const u8) = .empty; | |
703 | + defer commit_blocks.deinit(self.allocator); | |
704 | + const split_result = try splitBlocks(self.allocator, all_commit_content); | |
705 | + defer { | |
706 | + for (split_result) |blk| self.allocator.free(blk); | |
707 | + self.allocator.free(split_result); | |
708 | + } | |
709 | + for (split_result) |block| { | |
710 | + const trimmed = mem.trim(u8, block, " \t\n\r"); | |
711 | + if (trimmed.len > 0 and mem.indexOf(u8, trimmed, "## Commit `") != null) { | |
712 | + try commit_blocks.append(self.allocator, trimmed); | |
713 | + } | |
714 | + } | |
715 | + | |
716 | + // Build filtered commit content (last K blocks) | |
717 | + var filtered_buf: std.ArrayListUnmanaged(u8) = .empty; | |
718 | + defer filtered_buf.deinit(self.allocator); | |
719 | + const total = commit_blocks.items.len; | |
720 | + const start_idx = if (total > k) total - k else 0; | |
721 | + for (commit_blocks.items[start_idx..]) |block| { | |
722 | + const writer = filtered_buf.writer(self.allocator); | |
723 | + try writer.writeAll(block); | |
724 | + try writer.writeAll("\n\n---\n"); | |
725 | + } | |
726 | + const commit_history = try self.allocator.dupe(u8, filtered_buf.items); | |
454 | 727 | |
455 | 728 | return ContextSnapshot{ |
456 | 729 | .branch_name = target, |
457 | 730 | .k = k, |
458 | 731 | .main_roadmap = roadmap, |
459 | 732 | .ota_log = ota_content, |
460 | - .commit_history = commit_content, | |
733 | + .commit_history = commit_history, | |
461 | 734 | }; |
462 | 735 | } |
463 | 736 | |
737 | + /// List all branches in the workspace. | |
738 | + pub fn listBranches(self: Self) ![][]u8 { | |
739 | + const branches_path = try fs.path.join(self.allocator, &.{ self.root, GCC_DIR, "branches" }); | |
740 | + defer self.allocator.free(branches_path); | |
741 | + | |
742 | + var dir = fs.openDirAbsolute(branches_path, .{ .iterate = true }) catch return &[_][]u8{}; | |
743 | + defer dir.close(); | |
744 | + | |
745 | + var names: std.ArrayListUnmanaged([]u8) = .empty; | |
746 | + var dir_it = dir.iterate(); | |
747 | + while (try dir_it.next()) |entry| { | |
748 | + if (entry.kind == .directory) { | |
749 | + try names.append(self.allocator, try self.allocator.dupe(u8, entry.name)); | |
750 | + } | |
751 | + } | |
752 | + return names.toOwnedSlice(self.allocator); | |
753 | + } | |
754 | + | |
464 | 755 | pub fn currentBranch(self: Self) []const u8 { |
465 | 756 | return self.current_branch; |
466 | 757 | } |
467 | 758 | |
468 | - pub fn switchBranch(self: *Self, name: []const u8) void { | |
759 | + pub fn switchBranch(self: *Self, name: []const u8) ValidationError!void { | |
760 | + try validateBranchName(name); | |
469 | 761 | self.current_branch = name; |
470 | 762 | } |
471 | 763 | }; |
| @@ -479,6 +771,26 @@ pub const ContextSnapshot = struct { | ||
479 | 771 | ota_log: []const u8, |
480 | 772 | commit_history: []const u8, |
481 | 773 | |
774 | + /// Render a formatted markdown summary of this context. | |
775 | + pub fn summary(self: *const ContextSnapshot, allocator: mem.Allocator) ![]u8 { | |
776 | + var buf: std.ArrayListUnmanaged(u8) = .empty; | |
777 | + defer buf.deinit(allocator); | |
778 | + const writer = buf.writer(allocator); | |
779 | + | |
780 | + try writer.print("# CONTEXT — branch `{s}` (K={d})\n\n", .{ self.branch_name, self.k }); | |
781 | + try writer.writeAll("## Global Roadmap\n"); | |
782 | + try writer.writeAll(self.main_roadmap); | |
783 | + try writer.writeAll("\n\n"); | |
784 | + try writer.print("## Last {d} Commit(s)\n", .{self.k}); | |
785 | + try writer.writeAll(self.commit_history); | |
786 | + if (self.ota_log.len > 0) { | |
787 | + try writer.writeAll("\n## Recent OTA Steps\n"); | |
788 | + try writer.writeAll(self.ota_log); | |
789 | + } | |
790 | + | |
791 | + return allocator.dupe(u8, buf.items); | |
792 | + } | |
793 | + | |
482 | 794 | /// Free all owned memory. |
483 | 795 | pub fn deinit(self: *const ContextSnapshot, allocator: mem.Allocator) void { |
484 | 796 | allocator.free(self.main_roadmap); |
| @@ -487,6 +799,90 @@ pub const ContextSnapshot = struct { | ||
487 | 799 | } |
488 | 800 | }; |
489 | 801 | |
802 | +// ------------------------------------------------------------------ // | |
803 | +// Input sanitization // | |
804 | +// ------------------------------------------------------------------ // | |
805 | + | |
806 | +/// Escape content that could break the markdown separator format. | |
807 | +/// Replaces occurrences of "\n---\n" in user content with "\n\---\n" | |
808 | +/// to prevent parser confusion. | |
809 | +pub fn sanitizeContent(allocator: mem.Allocator, input: []const u8) ![]u8 { | |
810 | + var result: std.ArrayListUnmanaged(u8) = .empty; | |
811 | + defer result.deinit(allocator); | |
812 | + | |
813 | + var i: usize = 0; | |
814 | + while (i < input.len) { | |
815 | + if (i + 4 <= input.len and mem.eql(u8, input[i .. i + 4], "\n---")) { | |
816 | + // Check if followed by \n or end of string | |
817 | + if (i + 4 == input.len or input[i + 4] == '\n') { | |
818 | + try result.appendSlice(allocator, "\n\\---"); | |
819 | + i += 4; | |
820 | + continue; | |
821 | + } | |
822 | + } | |
823 | + try result.append(allocator, input[i]); | |
824 | + i += 1; | |
825 | + } | |
826 | + return allocator.dupe(u8, result.items); | |
827 | +} | |
828 | + | |
829 | +/// Reverse the escaping applied by sanitizeContent. | |
830 | +/// Replaces "\n\---\n" back to "\n---\n". | |
831 | +pub fn desanitizeContent(allocator: mem.Allocator, input: []const u8) ![]u8 { | |
832 | + var result: std.ArrayListUnmanaged(u8) = .empty; | |
833 | + defer result.deinit(allocator); | |
834 | + | |
835 | + var i: usize = 0; | |
836 | + while (i < input.len) { | |
837 | + if (i + 5 <= input.len and mem.eql(u8, input[i .. i + 5], "\n\\---")) { | |
838 | + if (i + 5 == input.len or input[i + 5] == '\n') { | |
839 | + try result.appendSlice(allocator, "\n---"); | |
840 | + i += 5; | |
841 | + continue; | |
842 | + } | |
843 | + } | |
844 | + try result.append(allocator, input[i]); | |
845 | + i += 1; | |
846 | + } | |
847 | + return allocator.dupe(u8, result.items); | |
848 | +} | |
849 | + | |
850 | +/// Split text on "---\n" separator while respecting escaped separators | |
851 | +/// ("\---\n" produced by sanitizeContent). Returns a list of blocks. | |
852 | +pub fn splitBlocks(allocator: mem.Allocator, text: []const u8) ![][]const u8 { | |
853 | + // First, naive split on "---\n" | |
854 | + var raw_parts: std.ArrayListUnmanaged([]const u8) = .empty; | |
855 | + defer raw_parts.deinit(allocator); | |
856 | + | |
857 | + var it = mem.splitSequence(u8, text, "---\n"); | |
858 | + while (it.next()) |part| { | |
859 | + try raw_parts.append(allocator, part); | |
860 | + } | |
861 | + | |
862 | + // Rejoin blocks where previous block ends with '\' | |
863 | + var blocks: std.ArrayListUnmanaged([]const u8) = .empty; | |
864 | + defer blocks.deinit(allocator); | |
865 | + | |
866 | + var i: usize = 0; | |
867 | + while (i < raw_parts.items.len) { | |
868 | + var block_buf: std.ArrayListUnmanaged(u8) = .empty; | |
869 | + defer block_buf.deinit(allocator); | |
870 | + try block_buf.appendSlice(allocator, raw_parts.items[i]); | |
871 | + | |
872 | + while (block_buf.items.len > 0 and | |
873 | + block_buf.items[block_buf.items.len - 1] == '\\' and | |
874 | + i + 1 < raw_parts.items.len) | |
875 | + { | |
876 | + i += 1; | |
877 | + try block_buf.appendSlice(allocator, "---\n"); | |
878 | + try block_buf.appendSlice(allocator, raw_parts.items[i]); | |
879 | + } | |
880 | + try blocks.append(allocator, try allocator.dupe(u8, block_buf.items)); | |
881 | + i += 1; | |
882 | + } | |
883 | + return try allocator.dupe([]const u8, blocks.items); | |
884 | +} | |
885 | + | |
490 | 886 | // ------------------------------------------------------------------ // |
491 | 887 | // Tests // |
492 | 888 | // ------------------------------------------------------------------ // |
| @@ -575,3 +971,82 @@ test "context returns roadmap and history" { | ||
575 | 971 | try std.testing.expect(mem.indexOf(u8, ctx.main_roadmap, "My AI project roadmap") != null); |
576 | 972 | try std.testing.expect(mem.indexOf(u8, ctx.commit_history, "First milestone") != null); |
577 | 973 | } |
974 | + | |
975 | +test "timestamp is not hardcoded" { | |
976 | + var tmp = std.testing.tmpDir(.{}); | |
977 | + defer tmp.cleanup(); | |
978 | + | |
979 | + const root = try tmp.dir.realpathAlloc(std.testing.allocator, "."); | |
980 | + defer std.testing.allocator.free(root); | |
981 | + | |
982 | + var ws = Workspace.init(std.testing.allocator, root); | |
983 | + const ts = try ws.nowTimestamp(); | |
984 | + defer std.testing.allocator.free(ts); | |
985 | + | |
986 | + // Should NOT be the old hardcoded value | |
987 | + try std.testing.expect(!mem.eql(u8, ts, "2025-01-01T00:00:00Z")); | |
988 | + // Should look like an ISO timestamp: YYYY-MM-DDTHH:MM:SSZ | |
989 | + try std.testing.expectEqual(@as(usize, 20), ts.len); | |
990 | + try std.testing.expectEqual(@as(u8, 'T'), ts[10]); | |
991 | + try std.testing.expectEqual(@as(u8, 'Z'), ts[19]); | |
992 | +} | |
993 | + | |
994 | +test "merge preserves branch metadata" { | |
995 | + var tmp = std.testing.tmpDir(.{}); | |
996 | + defer tmp.cleanup(); | |
997 | + | |
998 | + const root = try tmp.dir.realpathAlloc(std.testing.allocator, "."); | |
999 | + defer std.testing.allocator.free(root); | |
1000 | + | |
1001 | + var ws = Workspace.init(std.testing.allocator, root); | |
1002 | + try ws.create("Test"); | |
1003 | + try ws.branch("feature", "Implement auth system"); | |
1004 | + | |
1005 | + const c = try ws.commit("Auth done", null); | |
1006 | + defer std.testing.allocator.free(c.commit_id); | |
1007 | + const mc = try ws.merge("feature", "main"); | |
1008 | + defer std.testing.allocator.free(mc.commit_id); | |
1009 | + | |
1010 | + // Read metadata and verify original purpose is preserved | |
1011 | + const meta_file = try ws.metaFilePath("feature"); | |
1012 | + defer std.testing.allocator.free(meta_file); | |
1013 | + const meta_content = try ws.readFile(meta_file); | |
1014 | + defer std.testing.allocator.free(meta_content); | |
1015 | + | |
1016 | + try std.testing.expect(mem.indexOf(u8, meta_content, "Implement auth system") != null); | |
1017 | + try std.testing.expect(mem.indexOf(u8, meta_content, "merged") != null); | |
1018 | + try std.testing.expect(mem.indexOf(u8, meta_content, "merged_into: main") != null); | |
1019 | +} | |
1020 | + | |
1021 | +test "context K filters commits" { | |
1022 | + var tmp = std.testing.tmpDir(.{}); | |
1023 | + defer tmp.cleanup(); | |
1024 | + | |
1025 | + const root = try tmp.dir.realpathAlloc(std.testing.allocator, "."); | |
1026 | + defer std.testing.allocator.free(root); | |
1027 | + | |
1028 | + var ws = Workspace.init(std.testing.allocator, root); | |
1029 | + try ws.create("Test"); | |
1030 | + | |
1031 | + const c1 = try ws.commit("Commit one", null); | |
1032 | + defer std.testing.allocator.free(c1.commit_id); | |
1033 | + const c2 = try ws.commit("Commit two", null); | |
1034 | + defer std.testing.allocator.free(c2.commit_id); | |
1035 | + const c3 = try ws.commit("Commit three", null); | |
1036 | + defer std.testing.allocator.free(c3.commit_id); | |
1037 | + | |
1038 | + // K=1 should only return the last commit | |
1039 | + const ctx = try ws.context(null, 1); | |
1040 | + defer ctx.deinit(std.testing.allocator); | |
1041 | + | |
1042 | + try std.testing.expect(mem.indexOf(u8, ctx.commit_history, "Commit three") != null); | |
1043 | + try std.testing.expect(mem.indexOf(u8, ctx.commit_history, "Commit one") == null); | |
1044 | +} | |
1045 | + | |
1046 | +test "sanitizeContent escapes separators" { | |
1047 | + const input = "some text\n---\nmore text"; | |
1048 | + const result = try sanitizeContent(std.testing.allocator, input); | |
1049 | + defer std.testing.allocator.free(result); | |
1050 | + try std.testing.expect(mem.indexOf(u8, result, "\n---\n") == null); | |
1051 | + try std.testing.expect(mem.indexOf(u8, result, "\\---") != null); | |
1052 | +} | |
578 | 1053 | |