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
▼ZIG/README.md
+10-10
1
-# contexa
1
+# cortexa
2
2
3
3
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
4
-[![GitHub](https://img.shields.io/badge/GitHub-swadhinbiswas%2Fcontexa-black.svg?logo=github)](https://github.com/swadhinbiswas/contexa)
4
+[![GitHub](https://img.shields.io/badge/GitHub-swadhinbiswas%2FCortexa-black.svg?logo=github)](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
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
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
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
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
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
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
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
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
-//! 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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