refactor(elixir): rename module from 'contexa' to 'cortexa'
Swadhin Biswas committed 7 months ago
6dd3d24
1 parent d6fab51
commit 6dd3d24
8 files changed
+269 -149
Showing 8 changed files+269-149
▼ELIXIR/README.md
+4-4
| @@ -1,4 +1,4 @@ | ||
1 | -# contexa — Elixir | |
1 | +# cortexa — Elixir | |
2 | 2 | |
3 | 3 | Git-inspired context management for LLM agents — **COMMIT**, **BRANCH**, **MERGE**, and **CONTEXT** operations over a persistent versioned memory workspace. |
4 | 4 | |
| @@ -11,7 +11,7 @@ Add to your `mix.exs`: | ||
11 | 11 | ```elixir |
12 | 12 | def deps do |
13 | 13 | [ |
14 | - {:contexa, "~> 0.1.1"} | |
14 | + {:cortexa, "~> 0.1.1"} | |
15 | 15 | ] |
16 | 16 | end |
17 | 17 | ``` |
| @@ -19,7 +19,7 @@ end | ||
19 | 19 | ## Quick Start |
20 | 20 | |
21 | 21 | ```elixir |
22 | -alias Contexa.{Workspace, Models} | |
22 | +alias Cortexa.{Workspace, Models} | |
23 | 23 | |
24 | 24 | # Create workspace |
25 | 25 | ws = Workspace.new("/tmp/my-project") |
| @@ -46,7 +46,7 @@ IO.puts(Models.context_summary(ctx)) | ||
46 | 46 | |
47 | 47 | ## API |
48 | 48 | |
49 | -### `Contexa.Workspace` | |
49 | +### `Cortexa.Workspace` | |
50 | 50 | |
51 | 51 | | Function | Description | |
52 | 52 | |----------|-------------| |
53 | 53 | |
▼ELIXIR/lib/contexa.ex
+0-22
| @@ -1,22 +0,0 @@ | ||
1 | -defmodule Contexa do | |
2 | - @moduledoc """ | |
3 | - contexa — Git-inspired context management for LLM agents. | |
4 | - | |
5 | - COMMIT, BRANCH, MERGE, and CONTEXT operations over a persistent | |
6 | - versioned memory workspace. Based on arXiv:2508.00031. | |
7 | - | |
8 | - ## Quick Start | |
9 | - | |
10 | - ws = Contexa.Workspace.new("/tmp/my-project") | |
11 | - ws = Contexa.Workspace.init(ws, "Build an AI agent") | |
12 | - | |
13 | - {ws, _ota} = Contexa.Workspace.log_ota(ws, "Observed X", "Thinking Y", "Did Z") | |
14 | - {ws, _commit} = Contexa.Workspace.commit(ws, "Implemented feature") | |
15 | - | |
16 | - ws = Contexa.Workspace.branch(ws, "explore", "Try alternative approach") | |
17 | - {ws, _commit} = Contexa.Workspace.merge(ws, "explore") | |
18 | - | |
19 | - ctx = Contexa.Workspace.context(ws, "main", 3) | |
20 | - IO.puts(Contexa.Models.context_summary(ctx)) | |
21 | - """ | |
22 | -end | |
23 | 0 | |
▼ELIXIR/lib/cortexa.ex
+22-0
| @@ -0,0 +1,22 @@ | ||
1 | +defmodule Cortexa do | |
2 | + @moduledoc """ | |
3 | + cortexa — Git-inspired context management for LLM agents. | |
4 | + | |
5 | + COMMIT, BRANCH, MERGE, and CONTEXT operations over a persistent | |
6 | + versioned memory workspace. Based on arXiv:2508.00031. | |
7 | + | |
8 | + ## Quick Start | |
9 | + | |
10 | + ws = Cortexa.Workspace.new("/tmp/my-project") | |
11 | + ws = Cortexa.Workspace.init(ws, "Build an AI agent") | |
12 | + | |
13 | + {ws, _ota} = Cortexa.Workspace.log_ota(ws, "Observed X", "Thinking Y", "Did Z") | |
14 | + {ws, _commit} = Cortexa.Workspace.commit(ws, "Implemented feature") | |
15 | + | |
16 | + ws = Cortexa.Workspace.branch(ws, "explore", "Try alternative approach") | |
17 | + {ws, _commit} = Cortexa.Workspace.merge(ws, "explore") | |
18 | + | |
19 | + ctx = Cortexa.Workspace.context(ws, "main", 3) | |
20 | + IO.puts(Cortexa.Models.context_summary(ctx)) | |
21 | + """ | |
22 | +end | |
0 | 23 | |
▼ELIXIR/lib/contexa/models.ex → ELIXIR/lib/cortexa/models.ex
+59-17
| @@ -1,8 +1,8 @@ | ||
1 | -defmodule Contexa.Models do | |
1 | +defmodule Cortexa.Models do | |
2 | 2 | @moduledoc """ |
3 | - Data structures for the contexa GCC workspace. | |
3 | + Data structures for the cortexa GCC workspace. | |
4 | 4 | |
5 | - contexa — Git-inspired context management for LLM agents. | |
5 | + cortexa — Git-inspired context management for LLM agents. | |
6 | 6 | Based on arXiv:2508.00031. |
7 | 7 | """ |
8 | 8 | |
| @@ -139,6 +139,48 @@ defmodule Contexa.Models do | ||
139 | 139 | } |
140 | 140 | end |
141 | 141 | |
142 | + # ── Input sanitization ───────────────────────────────────────────── | |
143 | + | |
144 | + @doc "Escape separator sequences in user-provided content." | |
145 | + @spec sanitize(String.t()) :: String.t() | |
146 | + def sanitize(text) when is_binary(text) do | |
147 | + String.replace(text, "\n---\n", "\n\\---\n") | |
148 | + end | |
149 | + def sanitize(nil), do: "" | |
150 | + | |
151 | + @doc "Reverse the escaping applied by sanitize." | |
152 | + @spec desanitize(String.t()) :: String.t() | |
153 | + def desanitize(text) when is_binary(text) do | |
154 | + String.replace(text, "\n\\---\n", "\n---\n") | |
155 | + end | |
156 | + def desanitize(nil), do: "" | |
157 | + | |
158 | + @doc """ | |
159 | + Split markdown text on separator while respecting escaped separators. | |
160 | + | |
161 | + After naive splitting on the delimiter, blocks whose predecessor ends | |
162 | + with `\\` are rejoined (the backslash means `\\---\\n` was an escaped | |
163 | + separator produced by `sanitize/1`). | |
164 | + """ | |
165 | + @spec split_blocks(String.t(), String.t()) :: [String.t()] | |
166 | + def split_blocks(text, delim \\ "\n---\n") do | |
167 | + text | |
168 | + |> String.split(delim) | |
169 | + |> rejoin_escaped(delim, []) | |
170 | + end | |
171 | + | |
172 | + defp rejoin_escaped([], _delim, acc), do: Enum.reverse(acc) | |
173 | + defp rejoin_escaped([block | rest], delim, []) do | |
174 | + rejoin_escaped(rest, delim, [block]) | |
175 | + end | |
176 | + defp rejoin_escaped([block | rest], delim, [prev | acc]) do | |
177 | + if String.ends_with?(prev, "\\") do | |
178 | + rejoin_escaped(rest, delim, [prev <> delim <> block | acc]) | |
179 | + else | |
180 | + rejoin_escaped(rest, delim, [block, prev | acc]) | |
181 | + end | |
182 | + end | |
183 | + | |
142 | 184 | # ── Markdown serialization ──────────────────────────────────────── |
143 | 185 | |
144 | 186 | @doc "Render an OTARecord as markdown." |
| @@ -146,11 +188,11 @@ defmodule Contexa.Models do | ||
146 | 188 | def ota_to_markdown(%OTARecord{} = rec) do |
147 | 189 | """ |
148 | 190 | ### Step #{rec.step} — #{rec.timestamp} |
149 | - **Observation:** #{rec.observation} | |
191 | + **Observation:** #{sanitize(rec.observation)} | |
150 | 192 | |
151 | - **Thought:** #{rec.thought} | |
193 | + **Thought:** #{sanitize(rec.thought)} | |
152 | 194 | |
153 | - **Action:** #{rec.action} | |
195 | + **Action:** #{sanitize(rec.action)} | |
154 | 196 | |
155 | 197 | --- |
156 | 198 | """ |
| @@ -163,11 +205,11 @@ defmodule Contexa.Models do | ||
163 | 205 | ## Commit `#{rec.commit_id}` |
164 | 206 | **Timestamp:** #{rec.timestamp} |
165 | 207 | |
166 | - **Branch Purpose:** #{rec.branch_purpose} | |
208 | + **Branch Purpose:** #{sanitize(rec.branch_purpose)} | |
167 | 209 | |
168 | - **Previous Progress Summary:** #{rec.previous_progress_summary} | |
210 | + **Previous Progress Summary:** #{sanitize(rec.previous_progress_summary)} | |
169 | 211 | |
170 | - **This Commit's Contribution:** #{rec.this_commit_contribution} | |
212 | + **This Commit's Contribution:** #{sanitize(rec.this_commit_contribution)} | |
171 | 213 | |
172 | 214 | --- |
173 | 215 | """ |
| @@ -242,7 +284,7 @@ defmodule Contexa.Models do | ||
242 | 284 | @spec parse_commits(String.t()) :: [CommitRecord.t()] |
243 | 285 | def parse_commits(text) do |
244 | 286 | text |
245 | - |> String.split("\n---\n") | |
287 | + |> split_blocks("\n---\n") | |
246 | 288 | |> Enum.map(&String.trim/1) |
247 | 289 | |> Enum.filter(&(&1 != "")) |
248 | 290 | |> Enum.reduce([], fn block, acc -> |
| @@ -263,9 +305,9 @@ defmodule Contexa.Models do | ||
263 | 305 | commit_id: commit_id, |
264 | 306 | branch_name: "", |
265 | 307 | timestamp: extract.(~r/\*\*Timestamp:\*\*\s*(.+)/), |
266 | - branch_purpose: extract.(~r/\*\*Branch Purpose:\*\*\s*(.+)/), | |
267 | - previous_progress_summary: extract.(~r/\*\*Previous Progress Summary:\*\*\s*(.+)/), | |
268 | - this_commit_contribution: extract.(~r/\*\*This Commit's Contribution:\*\*\s*(.+)/) | |
308 | + branch_purpose: desanitize(extract.(~r/\*\*Branch Purpose:\*\*\s*([\s\S]+?)(?=\n\n\*\*|\z)/)), | |
309 | + previous_progress_summary: desanitize(extract.(~r/\*\*Previous Progress Summary:\*\*\s*([\s\S]+?)(?=\n\n\*\*|\z)/)), | |
310 | + this_commit_contribution: desanitize(extract.(~r/\*\*This Commit's Contribution:\*\*\s*([\s\S]+?)(?=\n\n\*\*|\n\n---|\z)/)) | |
269 | 311 | } |
270 | 312 | acc ++ [rec] |
271 | 313 | else |
| @@ -278,7 +320,7 @@ defmodule Contexa.Models do | ||
278 | 320 | @spec parse_ota(String.t()) :: [OTARecord.t()] |
279 | 321 | def parse_ota(text) do |
280 | 322 | text |
281 | - |> String.split("\n---\n") | |
323 | + |> split_blocks("\n---\n") | |
282 | 324 | |> Enum.map(&String.trim/1) |
283 | 325 | |> Enum.filter(&(&1 != "")) |
284 | 326 | |> Enum.reduce([], fn block, acc -> |
| @@ -294,9 +336,9 @@ defmodule Contexa.Models do | ||
294 | 336 | end |
295 | 337 | end |
296 | 338 | |
297 | - obs = extract.(~r/\*\*Observation:\*\*\s*(.+)/) | |
298 | - thought = extract.(~r/\*\*Thought:\*\*\s*(.+)/) | |
299 | - action = extract.(~r/\*\*Action:\*\*\s*(.+)/) | |
339 | + obs = desanitize(extract.(~r/\*\*Observation:\*\*\s*([\s\S]+?)(?=\n\n\*\*|\z)/)) | |
340 | + thought = desanitize(extract.(~r/\*\*Thought:\*\*\s*([\s\S]+?)(?=\n\n\*\*|\z)/)) | |
341 | + action = desanitize(extract.(~r/\*\*Action:\*\*\s*([\s\S]+?)(?=\n\n\*\*|\n\n---|\z)/)) | |
300 | 342 | |
301 | 343 | if obs != "" or thought != "" or action != "" do |
302 | 344 | rec = %OTARecord{ |
303 | 345 | |
▼ELIXIR/lib/contexa/workspace.ex → ELIXIR/lib/cortexa/workspace.ex
+173-95
| @@ -1,16 +1,17 @@ | ||
1 | -defmodule Contexa.Workspace do | |
1 | +defmodule Cortexa.Workspace do | |
2 | 2 | @moduledoc """ |
3 | 3 | GCCWorkspace — persistent versioned memory workspace for LLM agents. |
4 | 4 | |
5 | - contexa — Git-inspired context management. | |
5 | + cortexa — Git-inspired context management. | |
6 | 6 | Based on arXiv:2508.00031. |
7 | 7 | """ |
8 | 8 | |
9 | - alias Contexa.Models | |
10 | - alias Contexa.Models.{OTARecord, CommitRecord, ContextResult} | |
9 | + alias Cortexa.Models | |
10 | + alias Cortexa.Models.{OTARecord, CommitRecord, ContextResult} | |
11 | 11 | |
12 | 12 | @main_branch "main" |
13 | 13 | @gcc_dir ".GCC" |
14 | + @lock_timeout 10_000 # milliseconds | |
14 | 15 | |
15 | 16 | defstruct [:root, :gcc_dir, current_branch: "main"] |
16 | 17 | |
| @@ -20,6 +21,49 @@ defmodule Contexa.Workspace do | ||
20 | 21 | current_branch: String.t() |
21 | 22 | } |
22 | 23 | |
24 | + # ── Input validation ───────────────────────────────────────────── | |
25 | + | |
26 | + defp validate_not_empty!(value, field_name) do | |
27 | + if is_nil(value) or String.trim(to_string(value)) == "" do | |
28 | + raise ArgumentError, "#{field_name} must not be empty" | |
29 | + end | |
30 | + end | |
31 | + | |
32 | + defp validate_branch_name!(name) do | |
33 | + validate_not_empty!(name, "Branch name") | |
34 | + if String.contains?(name, "/") or String.contains?(name, "\\") do | |
35 | + raise ArgumentError, "Branch name must not contain '/' or '\\'" | |
36 | + end | |
37 | + if name == "." or name == ".." do | |
38 | + raise ArgumentError, "Branch name must not be '.' or '..'" | |
39 | + end | |
40 | + end | |
41 | + | |
42 | + # ── File locking ───────────────────────────────────────────────── | |
43 | + | |
44 | + @doc false | |
45 | + defp with_lock(ws, fun) do | |
46 | + lock_path = Path.join(ws.gcc_dir, ".lock") | |
47 | + File.mkdir_p!(Path.dirname(lock_path)) | |
48 | + | |
49 | + case :file.open(String.to_charlist(lock_path), [:write, :raw]) do | |
50 | + {:ok, fd} -> | |
51 | + try do | |
52 | + case :file.lock(fd, :exclusive) do | |
53 | + :ok -> | |
54 | + fun.() | |
55 | + {:error, reason} -> | |
56 | + raise "Failed to acquire lock: #{inspect(reason)}" | |
57 | + end | |
58 | + after | |
59 | + :file.unlock(fd) | |
60 | + :file.close(fd) | |
61 | + end | |
62 | + {:error, reason} -> | |
63 | + raise "Failed to open lock file: #{inspect(reason)}" | |
64 | + end | |
65 | + end | |
66 | + | |
23 | 67 | # ── Constructor ─────────────────────────────────────────────────── |
24 | 68 | |
25 | 69 | @doc "Create a new workspace handle." |
| @@ -44,32 +88,34 @@ defmodule Contexa.Workspace do | ||
44 | 88 | branch_dir = branch_dir(ws, @main_branch) |
45 | 89 | File.mkdir_p!(branch_dir) |
46 | 90 | |
47 | - ts = Models.timestamp() | |
48 | - | |
49 | - # main.md | |
50 | - File.write!( | |
51 | - Path.join(ws.gcc_dir, "main.md"), | |
52 | - "# Project Roadmap\n\n**Initialized:** #{ts}\n\n#{project_roadmap}\n" | |
53 | - ) | |
54 | - | |
55 | - # log.md | |
56 | - File.write!( | |
57 | - Path.join(branch_dir, "log.md"), | |
58 | - "# OTA Log — branch `main`\n\n" | |
59 | - ) | |
60 | - | |
61 | - # commit.md | |
62 | - File.write!( | |
63 | - Path.join(branch_dir, "commit.md"), | |
64 | - "# Commit History — branch `main`\n\n" | |
65 | - ) | |
66 | - | |
67 | - # metadata.yaml | |
68 | - meta = Models.new_branch_metadata(@main_branch, "Primary reasoning trajectory", "") | |
69 | - File.write!( | |
70 | - Path.join(branch_dir, "metadata.yaml"), | |
71 | - Models.metadata_to_yaml(meta) | |
72 | - ) | |
91 | + with_lock(ws, fn -> | |
92 | + ts = Models.timestamp() | |
93 | + | |
94 | + # main.md | |
95 | + File.write!( | |
96 | + Path.join(ws.gcc_dir, "main.md"), | |
97 | + "# Project Roadmap\n\n**Initialized:** #{ts}\n\n#{project_roadmap}\n" | |
98 | + ) | |
99 | + | |
100 | + # log.md | |
101 | + File.write!( | |
102 | + Path.join(branch_dir, "log.md"), | |
103 | + "# OTA Log — branch `main`\n\n" | |
104 | + ) | |
105 | + | |
106 | + # commit.md | |
107 | + File.write!( | |
108 | + Path.join(branch_dir, "commit.md"), | |
109 | + "# Commit History — branch `main`\n\n" | |
110 | + ) | |
111 | + | |
112 | + # metadata.yaml | |
113 | + meta = Models.new_branch_metadata(@main_branch, "Primary reasoning trajectory", "") | |
114 | + File.write!( | |
115 | + Path.join(branch_dir, "metadata.yaml"), | |
116 | + Models.metadata_to_yaml(meta) | |
117 | + ) | |
118 | + end) | |
73 | 119 | |
74 | 120 | %{ws | current_branch: @main_branch} |
75 | 121 | end |
| @@ -102,13 +148,22 @@ defmodule Contexa.Workspace do | ||
102 | 148 | @doc "Append an OTA cycle to the current branch." |
103 | 149 | @spec log_ota(t(), String.t(), String.t(), String.t()) :: {t(), OTARecord.t()} |
104 | 150 | def log_ota(ws, observation, thought, action) do |
105 | - dir = branch_dir(ws, ws.current_branch) | |
106 | - log_path = Path.join(dir, "log.md") | |
107 | - existing = read_file_safe(log_path) |> Models.parse_ota() | |
108 | - step = length(existing) + 1 | |
109 | - rec = Models.new_ota(step, observation, thought, action) | |
110 | - File.write!(log_path, read_file_safe(log_path) <> Models.ota_to_markdown(rec)) | |
111 | - {ws, rec} | |
151 | + obs_blank = is_nil(observation) or String.trim(to_string(observation)) == "" | |
152 | + tht_blank = is_nil(thought) or String.trim(to_string(thought)) == "" | |
153 | + act_blank = is_nil(action) or String.trim(to_string(action)) == "" | |
154 | + if obs_blank and tht_blank and act_blank do | |
155 | + raise ArgumentError, "At least one of observation, thought, or action must be non-empty" | |
156 | + end | |
157 | + | |
158 | + with_lock(ws, fn -> | |
159 | + dir = branch_dir(ws, ws.current_branch) | |
160 | + log_path = Path.join(dir, "log.md") | |
161 | + existing = read_file_safe(log_path) |> Models.parse_ota() | |
162 | + step = length(existing) + 1 | |
163 | + rec = Models.new_ota(step, observation, thought, action) | |
164 | + File.write!(log_path, read_file_safe(log_path) <> Models.ota_to_markdown(rec)) | |
165 | + {ws, rec} | |
166 | + end) | |
112 | 167 | end |
113 | 168 | |
114 | 169 | # ── Commit ─────────────────────────────────────────────────────── |
| @@ -116,6 +171,15 @@ defmodule Contexa.Workspace do | ||
116 | 171 | @doc "COMMIT — checkpoint a milestone on the current branch." |
117 | 172 | @spec commit(t(), String.t(), String.t() | nil, String.t() | nil) :: {t(), CommitRecord.t()} |
118 | 173 | def commit(ws, contribution, previous_summary \\ nil, update_roadmap \\ nil) do |
174 | + validate_not_empty!(contribution, "Contribution") | |
175 | + | |
176 | + with_lock(ws, fn -> | |
177 | + commit_inner(ws, contribution, previous_summary, update_roadmap) | |
178 | + end) | |
179 | + end | |
180 | + | |
181 | + @doc false | |
182 | + defp commit_inner(ws, contribution, previous_summary, update_roadmap) do | |
119 | 183 | dir = branch_dir(ws, ws.current_branch) |
120 | 184 | |
121 | 185 | meta = Path.join(dir, "metadata.yaml") |
| @@ -157,6 +221,9 @@ defmodule Contexa.Workspace do | ||
157 | 221 | @doc "BRANCH — create an isolated reasoning workspace." |
158 | 222 | @spec branch(t(), String.t(), String.t()) :: t() |
159 | 223 | def branch(ws, name, purpose) do |
224 | + validate_branch_name!(name) | |
225 | + validate_not_empty!(purpose, "Branch purpose") | |
226 | + | |
160 | 227 | dir = branch_dir(ws, name) |
161 | 228 | if File.dir?(dir) do |
162 | 229 | raise "Branch already exists: #{name}" |
| @@ -164,20 +231,22 @@ defmodule Contexa.Workspace do | ||
164 | 231 | |
165 | 232 | File.mkdir_p!(dir) |
166 | 233 | |
167 | - File.write!( | |
168 | - Path.join(dir, "log.md"), | |
169 | - "# OTA Log — branch `#{name}`\n\n" | |
170 | - ) | |
171 | - File.write!( | |
172 | - Path.join(dir, "commit.md"), | |
173 | - "# Commit History — branch `#{name}`\n\n" | |
174 | - ) | |
175 | - | |
176 | - meta = Models.new_branch_metadata(name, purpose, ws.current_branch) | |
177 | - File.write!( | |
178 | - Path.join(dir, "metadata.yaml"), | |
179 | - Models.metadata_to_yaml(meta) | |
180 | - ) | |
234 | + with_lock(ws, fn -> | |
235 | + File.write!( | |
236 | + Path.join(dir, "log.md"), | |
237 | + "# OTA Log — branch `#{name}`\n\n" | |
238 | + ) | |
239 | + File.write!( | |
240 | + Path.join(dir, "commit.md"), | |
241 | + "# Commit History — branch `#{name}`\n\n" | |
242 | + ) | |
243 | + | |
244 | + meta = Models.new_branch_metadata(name, purpose, ws.current_branch) | |
245 | + File.write!( | |
246 | + Path.join(dir, "metadata.yaml"), | |
247 | + Models.metadata_to_yaml(meta) | |
248 | + ) | |
249 | + end) | |
181 | 250 | |
182 | 251 | %{ws | current_branch: name} |
183 | 252 | end |
| @@ -187,6 +256,7 @@ defmodule Contexa.Workspace do | ||
187 | 256 | @doc "Switch to an existing branch." |
188 | 257 | @spec switch_branch(t(), String.t()) :: t() |
189 | 258 | def switch_branch(ws, name) do |
259 | + validate_branch_name!(name) | |
190 | 260 | dir = branch_dir(ws, name) |
191 | 261 | unless File.dir?(dir) do |
192 | 262 | raise "Branch does not exist: #{name}" |
| @@ -216,44 +286,49 @@ defmodule Contexa.Workspace do | ||
216 | 286 | @doc "MERGE — integrate a branch back into a target." |
217 | 287 | @spec merge(t(), String.t(), String.t() | nil, String.t()) :: {t(), CommitRecord.t()} |
218 | 288 | def merge(ws, branch_name, summary \\ nil, target \\ "main") do |
289 | + validate_branch_name!(branch_name) | |
290 | + validate_branch_name!(target) | |
291 | + | |
219 | 292 | src_dir = branch_dir(ws, branch_name) |
220 | 293 | tgt_dir = branch_dir(ws, target) |
221 | 294 | |
222 | 295 | unless File.dir?(src_dir), do: raise("Source branch does not exist: #{branch_name}") |
223 | 296 | unless File.dir?(tgt_dir), do: raise("Target branch does not exist: #{target}") |
224 | 297 | |
225 | - src_commits = Path.join(src_dir, "commit.md") |> read_file_safe() |> Models.parse_commits() | |
226 | - src_ota = Path.join(src_dir, "log.md") |> read_file_safe() |> Models.parse_ota() | |
227 | - src_meta = Path.join(src_dir, "metadata.yaml") |> read_file_safe() |> Models.metadata_from_yaml() | |
298 | + with_lock(ws, fn -> | |
299 | + src_commits = Path.join(src_dir, "commit.md") |> read_file_safe() |> Models.parse_commits() | |
300 | + src_ota = Path.join(src_dir, "log.md") |> read_file_safe() |> Models.parse_ota() | |
301 | + src_meta = Path.join(src_dir, "metadata.yaml") |> read_file_safe() |> Models.metadata_from_yaml() | |
228 | 302 | |
229 | - summary = if is_nil(summary) or summary == "" do | |
230 | - contributions = Enum.map(src_commits, & &1.this_commit_contribution) |> Enum.join(" | ") | |
231 | - "Merged branch `#{branch_name}` (#{length(src_commits)} commits). Contributions: #{contributions}" | |
232 | - else | |
233 | - summary | |
234 | - end | |
303 | + summary = if is_nil(summary) or summary == "" do | |
304 | + contributions = Enum.map(src_commits, & &1.this_commit_contribution) |> Enum.join(" | ") | |
305 | + "Merged branch `#{branch_name}` (#{length(src_commits)} commits). Contributions: #{contributions}" | |
306 | + else | |
307 | + summary | |
308 | + end | |
235 | 309 | |
236 | - # Append OTA records from source to target | |
237 | - if length(src_ota) > 0 do | |
238 | - ts = Models.timestamp() | |
239 | - tgt_log = Path.join(tgt_dir, "log.md") | |
240 | - header = "\n## Merged from `#{branch_name}` (#{ts})\n\n" | |
241 | - ota_text = Enum.map(src_ota, &Models.ota_to_markdown/1) |> Enum.join("") | |
242 | - File.write!(tgt_log, read_file_safe(tgt_log) <> header <> ota_text) | |
243 | - end | |
310 | + # Append OTA records from source to target | |
311 | + if length(src_ota) > 0 do | |
312 | + ts = Models.timestamp() | |
313 | + tgt_log = Path.join(tgt_dir, "log.md") | |
314 | + header = "\n## Merged from `#{branch_name}` (#{ts})\n\n" | |
315 | + ota_text = Enum.map(src_ota, &Models.ota_to_markdown/1) |> Enum.join("") | |
316 | + File.write!(tgt_log, read_file_safe(tgt_log) <> header <> ota_text) | |
317 | + end | |
244 | 318 | |
245 | - # Switch to target and create merge commit | |
246 | - ws = %{ws | current_branch: target} | |
247 | - prev = "Merging branch `#{branch_name}` with purpose: #{src_meta.purpose || ""}" | |
248 | - roadmap_update = "Merged `#{branch_name}`: #{summary}" | |
249 | - {ws, commit_rec} = commit(ws, summary, prev, roadmap_update) | |
319 | + # Switch to target and create merge commit (using inner to avoid double-lock) | |
320 | + ws = %{ws | current_branch: target} | |
321 | + prev = "Merging branch `#{branch_name}` with purpose: #{src_meta.purpose || ""}" | |
322 | + roadmap_update = "Merged `#{branch_name}`: #{summary}" | |
323 | + {ws, commit_rec} = commit_inner(ws, summary, prev, roadmap_update) | |
250 | 324 | |
251 | - # Update source metadata | |
252 | - ts = Models.timestamp() | |
253 | - updated_meta = %{src_meta | status: "merged", merged_into: target, merged_at: ts} | |
254 | - File.write!(Path.join(src_dir, "metadata.yaml"), Models.metadata_to_yaml(updated_meta)) | |
325 | + # Update source metadata | |
326 | + ts = Models.timestamp() | |
327 | + updated_meta = %{src_meta | status: "merged", merged_into: target, merged_at: ts} | |
328 | + File.write!(Path.join(src_dir, "metadata.yaml"), Models.metadata_to_yaml(updated_meta)) | |
255 | 329 | |
256 | - {ws, commit_rec} | |
330 | + {ws, commit_rec} | |
331 | + end) | |
257 | 332 | end |
258 | 333 | |
259 | 334 | # ── Context ────────────────────────────────────────────────────── |
| @@ -261,27 +336,30 @@ defmodule Contexa.Workspace do | ||
261 | 336 | @doc "CONTEXT — hierarchical memory retrieval." |
262 | 337 | @spec context(t(), String.t() | nil, non_neg_integer()) :: ContextResult.t() |
263 | 338 | def context(ws, branch_name \\ nil, k \\ 1) do |
339 | + if k < 1, do: raise(ArgumentError, "k must be >= 1") | |
264 | 340 | branch_name = branch_name || ws.current_branch |
265 | 341 | dir = branch_dir(ws, branch_name) |
266 | 342 | |
267 | 343 | unless File.dir?(dir), do: raise("Branch does not exist: #{branch_name}") |
268 | 344 | |
269 | - all_commits = Path.join(dir, "commit.md") |> read_file_safe() |> Models.parse_commits() | |
270 | - all_ota = Path.join(dir, "log.md") |> read_file_safe() |> Models.parse_ota() | |
271 | - roadmap = Path.join(ws.gcc_dir, "main.md") |> read_file_safe() | |
272 | - | |
273 | - meta_text = Path.join(dir, "metadata.yaml") |> read_file_safe() | |
274 | - meta = if meta_text != "", do: Models.metadata_from_yaml(meta_text), else: nil | |
275 | - | |
276 | - commits = Enum.take(all_commits, -k) | |
277 | - | |
278 | - %ContextResult{ | |
279 | - branch_name: branch_name, | |
280 | - k: k, | |
281 | - commits: commits, | |
282 | - ota_records: all_ota, | |
283 | - main_roadmap: roadmap, | |
284 | - metadata: meta | |
285 | - } | |
345 | + with_lock(ws, fn -> | |
346 | + all_commits = Path.join(dir, "commit.md") |> read_file_safe() |> Models.parse_commits() | |
347 | + all_ota = Path.join(dir, "log.md") |> read_file_safe() |> Models.parse_ota() | |
348 | + roadmap = Path.join(ws.gcc_dir, "main.md") |> read_file_safe() | |
349 | + | |
350 | + meta_text = Path.join(dir, "metadata.yaml") |> read_file_safe() | |
351 | + meta = if meta_text != "", do: Models.metadata_from_yaml(meta_text), else: nil | |
352 | + | |
353 | + commits = Enum.take(all_commits, -k) | |
354 | + | |
355 | + %ContextResult{ | |
356 | + branch_name: branch_name, | |
357 | + k: k, | |
358 | + commits: commits, | |
359 | + ota_records: all_ota, | |
360 | + main_roadmap: roadmap, | |
361 | + metadata: meta | |
362 | + } | |
363 | + end) | |
286 | 364 | end |
287 | 365 | end |
288 | 366 | |
▼ELIXIR/mix.exs
+4-4
| @@ -1,12 +1,12 @@ | ||
1 | -defmodule Contexa.MixProject do | |
1 | +defmodule Cortexa.MixProject do | |
2 | 2 | use Mix.Project |
3 | 3 | |
4 | 4 | @version "0.1.1" |
5 | - @source_url "https://github.com/swadhinbiswas/contexa" | |
5 | + @source_url "https://github.com/swadhinbiswas/Cortexa" | |
6 | 6 | |
7 | 7 | def project do |
8 | 8 | [ |
9 | - app: :contexa, | |
9 | + app: :cortexa, | |
10 | 10 | version: @version, |
11 | 11 | elixir: "~> 1.14", |
12 | 12 | start_permanent: Mix.env() == :prod, |
| @@ -45,7 +45,7 @@ defmodule Contexa.MixProject do | ||
45 | 45 | |
46 | 46 | defp package do |
47 | 47 | [ |
48 | - name: "contexa", | |
48 | + name: "cortexa", | |
49 | 49 | licenses: ["MIT"], |
50 | 50 | links: %{ |
51 | 51 | "GitHub" => @source_url, |
52 | 52 | |
▼ELIXIR/test/models_test.exs
+3-3
| @@ -1,8 +1,8 @@ | ||
1 | -defmodule Contexa.ModelsTest do | |
1 | +defmodule Cortexa.ModelsTest do | |
2 | 2 | use ExUnit.Case, async: true |
3 | 3 | |
4 | - alias Contexa.Models | |
5 | - alias Contexa.Models.{OTARecord, CommitRecord, BranchMetadata} | |
4 | + alias Cortexa.Models | |
5 | + alias Cortexa.Models.{OTARecord, CommitRecord, BranchMetadata} | |
6 | 6 | |
7 | 7 | test "generate_id returns 8 hex chars" do |
8 | 8 | id = Models.generate_id() |
9 | 9 | |
▼ELIXIR/test/workspace_test.exs
+4-4
| @@ -1,11 +1,11 @@ | ||
1 | -defmodule Contexa.WorkspaceTest do | |
1 | +defmodule Cortexa.WorkspaceTest do | |
2 | 2 | use ExUnit.Case, async: true |
3 | 3 | |
4 | - alias Contexa.Workspace | |
5 | - alias Contexa.Models | |
4 | + alias Cortexa.Workspace | |
5 | + alias Cortexa.Models | |
6 | 6 | |
7 | 7 | defp make_tmp do |
8 | - dir = Path.join(System.tmp_dir!(), "contexa_ex_test_#{System.unique_integer([:positive])}") | |
8 | + dir = Path.join(System.tmp_dir!(), "cortexa_ex_test_#{System.unique_integer([:positive])}") | |
9 | 9 | File.rm_rf!(dir) |
10 | 10 | File.mkdir_p!(dir) |
11 | 11 | dir |
12 | 12 | |