helix-memory 4.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- helix/__init__.py +3 -0
- helix/cli/__init__.py +19 -0
- helix/cli/app.py +21 -0
- helix/cli/commands.py +136 -0
- helix/cli/prompts.py +33 -0
- helix/core/__init__.py +35 -0
- helix/core/conventions/__init__.py +7 -0
- helix/core/conventions/brain.py +127 -0
- helix/core/conventions/convention.py +53 -0
- helix/core/installer/__init__.py +30 -0
- helix/core/installer/models.py +35 -0
- helix/core/installer/operations.py +114 -0
- helix/core/installer/snippet.py +18 -0
- helix/core/settings.py +23 -0
- helix/utils/__init__.py +3 -0
- helix/utils/parsing.py +4 -0
- helix_memory-4.0.1.dist-info/METADATA +74 -0
- helix_memory-4.0.1.dist-info/RECORD +21 -0
- helix_memory-4.0.1.dist-info/WHEEL +4 -0
- helix_memory-4.0.1.dist-info/entry_points.txt +2 -0
- helix_memory-4.0.1.dist-info/licenses/LICENSE +21 -0
helix/__init__.py
ADDED
helix/cli/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .app import app
|
|
2
|
+
from .commands import (
|
|
3
|
+
cmd_forget,
|
|
4
|
+
cmd_install,
|
|
5
|
+
cmd_list,
|
|
6
|
+
cmd_recall,
|
|
7
|
+
cmd_remember,
|
|
8
|
+
cmd_uninstall,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"app",
|
|
13
|
+
"cmd_forget",
|
|
14
|
+
"cmd_install",
|
|
15
|
+
"cmd_list",
|
|
16
|
+
"cmd_recall",
|
|
17
|
+
"cmd_remember",
|
|
18
|
+
"cmd_uninstall",
|
|
19
|
+
]
|
helix/cli/app.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from helix.cli.commands import COMMANDS
|
|
4
|
+
from helix.core import Brain
|
|
5
|
+
|
|
6
|
+
app = typer.Typer()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@app.callback(invoke_without_command=True)
|
|
10
|
+
def main(ctx: typer.Context) -> None:
|
|
11
|
+
Brain().initialize()
|
|
12
|
+
if ctx.invoked_subcommand is None:
|
|
13
|
+
typer.echo("Helix — global convention memory. Run `helix --help` for commands.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
for name, command in COMMANDS.items():
|
|
17
|
+
app.command(name)(command)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__": # pragma: no cover
|
|
21
|
+
app()
|
helix/cli/commands.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from helix.core import Brain, Scope, detect_snippet_blocks, install, uninstall
|
|
9
|
+
from helix.core.installer import clients as all_clients
|
|
10
|
+
from helix.core.installer import detect_installed_clients
|
|
11
|
+
from helix.utils import parse_csv
|
|
12
|
+
|
|
13
|
+
from .prompts import pick, pick_many
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cmd_remember(
|
|
17
|
+
name: Annotated[str, typer.Argument(help="Convention name (kebab-case).")],
|
|
18
|
+
body: Annotated[str, typer.Argument(help="Convention body text.")],
|
|
19
|
+
tags: Annotated[str | None, typer.Option(help="Comma-separated tags.")] = None,
|
|
20
|
+
applies_to: Annotated[
|
|
21
|
+
str | None, typer.Option(help="Comma-separated stacks/scopes.")
|
|
22
|
+
] = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
path = Brain().remember(
|
|
25
|
+
name=name,
|
|
26
|
+
body=body,
|
|
27
|
+
tags=parse_csv(tags),
|
|
28
|
+
applies_to=parse_csv(applies_to),
|
|
29
|
+
)
|
|
30
|
+
typer.echo(f"Saved as {path.name}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cmd_list(
|
|
34
|
+
tags: Annotated[
|
|
35
|
+
str | None, typer.Option(help="Filter by comma-separated tags.")
|
|
36
|
+
] = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
lines = Brain().list_conventions(tags=parse_csv(tags))
|
|
39
|
+
if not lines:
|
|
40
|
+
typer.echo("No conventions found.")
|
|
41
|
+
return
|
|
42
|
+
for line in lines:
|
|
43
|
+
typer.echo(line)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_recall(
|
|
47
|
+
query: Annotated[str, typer.Argument(help="Substring to search for.")],
|
|
48
|
+
tags: Annotated[
|
|
49
|
+
str | None, typer.Option(help="Filter by comma-separated tags.")
|
|
50
|
+
] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
results: list[str] = Brain().recall(query=query, tags=parse_csv(tags))
|
|
53
|
+
if not results:
|
|
54
|
+
typer.echo("No matches found.")
|
|
55
|
+
return
|
|
56
|
+
for line in results:
|
|
57
|
+
typer.echo(line)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_forget(
|
|
61
|
+
name: Annotated[str, typer.Argument(help="Convention name to remove.")],
|
|
62
|
+
) -> None:
|
|
63
|
+
if Brain().forget(name):
|
|
64
|
+
typer.echo(f"Removed {name}")
|
|
65
|
+
else:
|
|
66
|
+
typer.echo(f"Convention '{name}' not found.", err=True)
|
|
67
|
+
raise typer.Exit(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_install() -> None:
|
|
71
|
+
detected = detect_installed_clients()
|
|
72
|
+
available = detected or all_clients()
|
|
73
|
+
if not detected:
|
|
74
|
+
typer.echo("No client config directories found; showing all known clients.")
|
|
75
|
+
|
|
76
|
+
typer.echo("Pick client(s):")
|
|
77
|
+
selected = [available[i] for i in pick_many("Clients", [c.name for c in available])]
|
|
78
|
+
|
|
79
|
+
project_root = Path.cwd()
|
|
80
|
+
typer.echo("Pick a scope:")
|
|
81
|
+
scope = (Scope.GLOBAL, Scope.PROJECT)[
|
|
82
|
+
pick("Scope", ["global (per-user config dir)", "project (this repo)"])
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
written: set[Path] = set()
|
|
86
|
+
for client in selected:
|
|
87
|
+
path = client.path_for(scope, project_root)
|
|
88
|
+
if path in written: # pragma: no cover
|
|
89
|
+
typer.echo(f"Skipped {client.name}: {path} already written this run")
|
|
90
|
+
continue
|
|
91
|
+
install(client, scope, project_root)
|
|
92
|
+
written.add(path)
|
|
93
|
+
typer.echo(f"Wrote helix block to {path} ({client.name})")
|
|
94
|
+
|
|
95
|
+
if not shutil.which("helix"): # pragma: no cover
|
|
96
|
+
typer.echo(
|
|
97
|
+
"\nWarning: 'helix' is not on PATH. The installed snippet tells agents "
|
|
98
|
+
"to run `helix list`, which will fail until the CLI is installed on "
|
|
99
|
+
"PATH (e.g. `pipx install helix` or `uv tool install helix`).",
|
|
100
|
+
err=True,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_uninstall() -> None:
|
|
105
|
+
project_root = Path.cwd()
|
|
106
|
+
installed = detect_snippet_blocks(project_root)
|
|
107
|
+
if not installed:
|
|
108
|
+
typer.echo("No helix blocks found.")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
typer.echo("Pick block(s) to remove:")
|
|
112
|
+
labels = [
|
|
113
|
+
f"{block.client.name} [{block.scope}] — {block.path}" for block in installed
|
|
114
|
+
]
|
|
115
|
+
selected = [installed[i] for i in pick_many("Blocks", labels)]
|
|
116
|
+
|
|
117
|
+
removed_any = False
|
|
118
|
+
for block in selected:
|
|
119
|
+
if uninstall(block.client, block.scope, project_root):
|
|
120
|
+
typer.echo(f"Removed helix block from {block.path}")
|
|
121
|
+
removed_any = True
|
|
122
|
+
else:
|
|
123
|
+
typer.echo(f"Nothing to remove from {block.path}", err=True)
|
|
124
|
+
|
|
125
|
+
if not removed_any:
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
COMMANDS: dict[str, Callable[..., None]] = {
|
|
130
|
+
"forget": cmd_forget,
|
|
131
|
+
"install": cmd_install,
|
|
132
|
+
"list": cmd_list,
|
|
133
|
+
"recall": cmd_recall,
|
|
134
|
+
"remember": cmd_remember,
|
|
135
|
+
"uninstall": cmd_uninstall,
|
|
136
|
+
}
|
helix/cli/prompts.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Interactive selection helpers for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pick(prompt: str, options: list[str]) -> int: # pragma: no cover
|
|
7
|
+
"""Prompt for a single choice and return its zero-based index."""
|
|
8
|
+
for index, label in enumerate(options, 1):
|
|
9
|
+
typer.echo(f" {index}) {label}")
|
|
10
|
+
choice = typer.prompt(prompt, type=int, default=1)
|
|
11
|
+
if not 1 <= choice <= len(options):
|
|
12
|
+
typer.echo("Invalid choice.", err=True)
|
|
13
|
+
raise typer.Exit(1)
|
|
14
|
+
return int(choice) - 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pick_many(prompt: str, options: list[str]) -> list[int]: # pragma: no cover
|
|
18
|
+
"""Prompt for one or more choices and return their zero-based indices."""
|
|
19
|
+
for index, label in enumerate(options, 1):
|
|
20
|
+
typer.echo(f" {index}) {label}")
|
|
21
|
+
raw = typer.prompt(f"{prompt} (comma-separated, or 'all')", default="all")
|
|
22
|
+
if raw.strip().lower() == "all":
|
|
23
|
+
return list(range(len(options)))
|
|
24
|
+
chosen: list[int] = []
|
|
25
|
+
for raw_part in raw.split(","):
|
|
26
|
+
part = raw_part.strip()
|
|
27
|
+
if not part.isdigit() or not 1 <= int(part) <= len(options):
|
|
28
|
+
typer.echo(f"Invalid choice: {part!r}", err=True)
|
|
29
|
+
raise typer.Exit(1)
|
|
30
|
+
index = int(part) - 1
|
|
31
|
+
if index not in chosen:
|
|
32
|
+
chosen.append(index)
|
|
33
|
+
return chosen
|
helix/core/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# pylint: disable=duplicate-code
|
|
2
|
+
from .conventions import Brain, Convention
|
|
3
|
+
from .installer import (
|
|
4
|
+
BLOCK_PATTERN,
|
|
5
|
+
END_MARKER,
|
|
6
|
+
SNIPPET,
|
|
7
|
+
START_MARKER,
|
|
8
|
+
Client,
|
|
9
|
+
Scope,
|
|
10
|
+
SnippetBlock,
|
|
11
|
+
clients,
|
|
12
|
+
detect_installed_clients,
|
|
13
|
+
detect_snippet_blocks,
|
|
14
|
+
install,
|
|
15
|
+
uninstall,
|
|
16
|
+
)
|
|
17
|
+
from .settings import Settings
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BLOCK_PATTERN",
|
|
21
|
+
"END_MARKER",
|
|
22
|
+
"SNIPPET",
|
|
23
|
+
"START_MARKER",
|
|
24
|
+
"Brain",
|
|
25
|
+
"Client",
|
|
26
|
+
"Convention",
|
|
27
|
+
"Scope",
|
|
28
|
+
"Settings",
|
|
29
|
+
"SnippetBlock",
|
|
30
|
+
"clients",
|
|
31
|
+
"detect_installed_clients",
|
|
32
|
+
"detect_snippet_blocks",
|
|
33
|
+
"install",
|
|
34
|
+
"uninstall",
|
|
35
|
+
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from helix.core.settings import Settings
|
|
4
|
+
|
|
5
|
+
from .convention import Convention
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Brain:
|
|
9
|
+
@property
|
|
10
|
+
def index(self) -> Path:
|
|
11
|
+
return Settings.HELIX_INDEX
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def conventions(self) -> Path:
|
|
15
|
+
return Settings.HELIX_CONVENTIONS
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def is_initialized(self) -> bool:
|
|
19
|
+
return self.index.exists()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def content(self) -> str:
|
|
23
|
+
return self.index.read_text()
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def index_lines(self) -> list[str]:
|
|
27
|
+
return self.content.splitlines(keepends=True)
|
|
28
|
+
|
|
29
|
+
def initialize(self) -> None:
|
|
30
|
+
self.conventions.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
if not self.is_initialized:
|
|
32
|
+
self.index.write_text("# Helix Convention Index\n\n")
|
|
33
|
+
|
|
34
|
+
def remember(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
name: str,
|
|
38
|
+
body: str,
|
|
39
|
+
tags: list[str],
|
|
40
|
+
applies_to: list[str] | None = None,
|
|
41
|
+
) -> Path:
|
|
42
|
+
convention = Convention(
|
|
43
|
+
name=name, body=body, tags=tags, applies_to=applies_to or []
|
|
44
|
+
)
|
|
45
|
+
convention.file_path.write_text(convention.to_markdown())
|
|
46
|
+
self._add_convention_to_index(convention)
|
|
47
|
+
|
|
48
|
+
return convention.file_path
|
|
49
|
+
|
|
50
|
+
def index_line_for(self, name: str) -> str | None:
|
|
51
|
+
return next(
|
|
52
|
+
(line for line in self.index_lines if line.startswith(f"- [{name}](")),
|
|
53
|
+
None,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def convention_for(self, name: str) -> Convention | None:
|
|
57
|
+
path = self.conventions / f"{name}.md"
|
|
58
|
+
if not path.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
return Convention.from_markdown(path.read_text())
|
|
62
|
+
except ValueError:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
def _add_convention_to_index(self, convention: Convention) -> None:
|
|
66
|
+
lines = [
|
|
67
|
+
line
|
|
68
|
+
for line in self.index_lines
|
|
69
|
+
if not line.startswith(f"- [{convention.name}](")
|
|
70
|
+
]
|
|
71
|
+
content = "".join(lines).rstrip("\n") + "\n"
|
|
72
|
+
content += convention.index_line() + "\n"
|
|
73
|
+
self.index.write_text(content)
|
|
74
|
+
|
|
75
|
+
def list_conventions(self, tags: list[str] | None = None) -> list[str]:
|
|
76
|
+
if not self.is_initialized:
|
|
77
|
+
return []
|
|
78
|
+
lines = [line for line in self.index_lines if line.startswith("- [")]
|
|
79
|
+
if not tags:
|
|
80
|
+
return lines
|
|
81
|
+
|
|
82
|
+
return self._filter_index_lines_by_tags(lines, tags)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _filter_index_lines_by_tags(lines: list[str], tags: list[str]) -> list[str]:
|
|
86
|
+
tags_set = set(tags)
|
|
87
|
+
return [
|
|
88
|
+
line for line in lines if Convention.tags_from_index_line(line) & tags_set
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def recall(self, query: str, tags: list[str] | None = None) -> list[str]:
|
|
92
|
+
lines = [
|
|
93
|
+
f"{path}:{line_number}:{line}"
|
|
94
|
+
for path in self.conventions.glob("*.md")
|
|
95
|
+
for line_number, line in enumerate(path.read_text().splitlines(), 1)
|
|
96
|
+
if query.lower() in line.lower()
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
if not tags:
|
|
100
|
+
return lines
|
|
101
|
+
|
|
102
|
+
tags_set = set(tags)
|
|
103
|
+
allowed_names = {
|
|
104
|
+
convention.name
|
|
105
|
+
for convention in self._load_conventions()
|
|
106
|
+
if tags_set & set(convention.tags)
|
|
107
|
+
}
|
|
108
|
+
return [line for line in lines if any(name in line for name in allowed_names)]
|
|
109
|
+
|
|
110
|
+
def _load_conventions(self) -> list[Convention]:
|
|
111
|
+
conventions = []
|
|
112
|
+
for path in self.conventions.glob("*.md"):
|
|
113
|
+
try:
|
|
114
|
+
conventions.append(Convention.from_markdown(path.read_text()))
|
|
115
|
+
except ValueError:
|
|
116
|
+
pass
|
|
117
|
+
return conventions
|
|
118
|
+
|
|
119
|
+
def forget(self, name: str) -> bool:
|
|
120
|
+
file_path = self.conventions / f"{name}.md"
|
|
121
|
+
if not file_path.exists():
|
|
122
|
+
return False
|
|
123
|
+
file_path.unlink()
|
|
124
|
+
existing_line = self.index_line_for(name)
|
|
125
|
+
if existing_line:
|
|
126
|
+
self.index.write_text(self.content.replace(existing_line, ""))
|
|
127
|
+
return True
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
import frontmatter
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from helix.core.settings import Settings
|
|
9
|
+
|
|
10
|
+
CONVENTION_REGEX = r"\[([^\]]*)\] —"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Convention(BaseModel):
|
|
14
|
+
name: str
|
|
15
|
+
body: str
|
|
16
|
+
tags: list[str] = Field(default_factory=list)
|
|
17
|
+
applies_to: list[str] = Field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(extra="ignore")
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def file_path(self) -> Path:
|
|
23
|
+
return Settings.HELIX_CONVENTIONS / f"{self.name}.md"
|
|
24
|
+
|
|
25
|
+
def to_markdown(self) -> str:
|
|
26
|
+
post = frontmatter.Post(
|
|
27
|
+
self.body,
|
|
28
|
+
name=self.name,
|
|
29
|
+
tags=self.tags,
|
|
30
|
+
applies_to=self.applies_to,
|
|
31
|
+
)
|
|
32
|
+
return str(frontmatter.dumps(post)) + "\n"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_markdown(cls, text: str) -> Self:
|
|
36
|
+
post = frontmatter.loads(text)
|
|
37
|
+
if not post.metadata.get("name"):
|
|
38
|
+
raise ValueError("Invalid convention file: missing 'name' field")
|
|
39
|
+
return cls.model_validate({"body": post.content, **post.metadata})
|
|
40
|
+
|
|
41
|
+
def index_line(self) -> str:
|
|
42
|
+
first_line = self.body.strip().splitlines()[0] if self.body.strip() else ""
|
|
43
|
+
if len(first_line) > 80:
|
|
44
|
+
first_line = first_line[:77] + "..."
|
|
45
|
+
tags_string = ",".join(self.tags)
|
|
46
|
+
return f"- [{self.name}](conventions/{self.name}.md) [{tags_string}] — {first_line}"
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def tags_from_index_line(line: str) -> set[str]:
|
|
50
|
+
match = re.search(CONVENTION_REGEX, line)
|
|
51
|
+
if not match:
|
|
52
|
+
return set()
|
|
53
|
+
return {tag.strip() for tag in match.group(1).split(",") if tag.strip()}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# pylint: disable=duplicate-code
|
|
2
|
+
from helix.core.installer.models import Client, Scope, SnippetBlock
|
|
3
|
+
from helix.core.installer.operations import (
|
|
4
|
+
clients,
|
|
5
|
+
detect_installed_clients,
|
|
6
|
+
detect_snippet_blocks,
|
|
7
|
+
install,
|
|
8
|
+
uninstall,
|
|
9
|
+
)
|
|
10
|
+
from helix.core.installer.snippet import (
|
|
11
|
+
BLOCK_PATTERN,
|
|
12
|
+
END_MARKER,
|
|
13
|
+
SNIPPET,
|
|
14
|
+
START_MARKER,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BLOCK_PATTERN",
|
|
19
|
+
"END_MARKER",
|
|
20
|
+
"SNIPPET",
|
|
21
|
+
"START_MARKER",
|
|
22
|
+
"Client",
|
|
23
|
+
"Scope",
|
|
24
|
+
"SnippetBlock",
|
|
25
|
+
"clients",
|
|
26
|
+
"detect_installed_clients",
|
|
27
|
+
"detect_snippet_blocks",
|
|
28
|
+
"install",
|
|
29
|
+
"uninstall",
|
|
30
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Scope(StrEnum):
|
|
8
|
+
GLOBAL = "global"
|
|
9
|
+
PROJECT = "project"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Client(BaseModel):
|
|
13
|
+
key: str = Field(description="Stable identifier for the client (e.g. 'claude').")
|
|
14
|
+
name: str = Field(description="Human-readable client name shown in CLI prompts.")
|
|
15
|
+
global_path: Path = Field(
|
|
16
|
+
description="Absolute path to the client's per-user (global) config file."
|
|
17
|
+
)
|
|
18
|
+
project_relative_path: Path = Field(
|
|
19
|
+
description="Config file path relative to a project root, for project scope."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def path_for(self, scope: Scope, project_root: Path) -> Path:
|
|
23
|
+
if scope == Scope.GLOBAL:
|
|
24
|
+
return self.global_path
|
|
25
|
+
return project_root / self.project_relative_path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SnippetBlock(BaseModel):
|
|
29
|
+
client: Client = Field(description="The client whose config file holds the block.")
|
|
30
|
+
scope: Scope = Field(
|
|
31
|
+
description="Whether the block was found in global or project scope."
|
|
32
|
+
)
|
|
33
|
+
path: Path = Field(
|
|
34
|
+
description="Resolved config file where the helix block was detected."
|
|
35
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from helix.core.settings import Settings
|
|
4
|
+
|
|
5
|
+
from .models import Client, Scope, SnippetBlock
|
|
6
|
+
from .snippet import BLOCK_PATTERN, END_MARKER, SNIPPET, START_MARKER
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def clients() -> list[Client]:
|
|
10
|
+
home = Settings.HOME_DIRECTORY
|
|
11
|
+
return [
|
|
12
|
+
Client(
|
|
13
|
+
key="claude",
|
|
14
|
+
name="Claude Code",
|
|
15
|
+
global_path=home / ".claude" / "CLAUDE.md",
|
|
16
|
+
project_relative_path=Path("CLAUDE.md"),
|
|
17
|
+
),
|
|
18
|
+
Client(
|
|
19
|
+
key="cursor",
|
|
20
|
+
name="Cursor",
|
|
21
|
+
global_path=home / ".cursor" / "rules" / "helix.mdc",
|
|
22
|
+
project_relative_path=Path(".cursor") / "rules" / "helix.mdc",
|
|
23
|
+
),
|
|
24
|
+
Client(
|
|
25
|
+
key="codex",
|
|
26
|
+
name="Codex CLI",
|
|
27
|
+
global_path=home / ".codex" / "AGENTS.md",
|
|
28
|
+
project_relative_path=Path("AGENTS.md"),
|
|
29
|
+
),
|
|
30
|
+
Client(
|
|
31
|
+
key="opencode",
|
|
32
|
+
name="Opencode",
|
|
33
|
+
global_path=home / ".config" / "opencode" / "AGENTS.md",
|
|
34
|
+
project_relative_path=Path("AGENTS.md"),
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def detect_installed_clients() -> list[Client]:
|
|
40
|
+
return [client for client in clients() if client.global_path.parent.exists()]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_snippet_blocks(project_root: Path) -> list[SnippetBlock]:
|
|
44
|
+
blocks: list[SnippetBlock] = []
|
|
45
|
+
for client in clients():
|
|
46
|
+
for scope in Scope:
|
|
47
|
+
path = client.path_for(scope, project_root)
|
|
48
|
+
if not path.exists():
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
if START_MARKER not in path.read_text():
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
config_block = SnippetBlock(client=client, scope=scope, path=path)
|
|
55
|
+
blocks.append(config_block)
|
|
56
|
+
return blocks
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _insert_snippet_block(existing: str, block: str) -> str:
|
|
60
|
+
"""Return ``existing`` with the Helix snippet block inserted or refreshed.
|
|
61
|
+
|
|
62
|
+
If a snippet block is already present, it is replaced in place
|
|
63
|
+
so reinstalling updates the snippet rather than duplicating it.
|
|
64
|
+
|
|
65
|
+
If there is other content but no existing block, the
|
|
66
|
+
block is appended after a blank-line separator.
|
|
67
|
+
|
|
68
|
+
Otherwise the block becomes the entire content.
|
|
69
|
+
"""
|
|
70
|
+
if BLOCK_PATTERN.search(existing):
|
|
71
|
+
return BLOCK_PATTERN.sub(block.rstrip("\n"), existing)
|
|
72
|
+
if existing.strip():
|
|
73
|
+
return existing.rstrip("\n") + "\n\n" + block
|
|
74
|
+
return block
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def install(client: Client, scope: Scope, project_root: Path) -> Path:
|
|
78
|
+
"""Write (or refresh) the Helix snippet block in the client's config file.
|
|
79
|
+
|
|
80
|
+
Creates the file and parent directories if needed, and returns the path
|
|
81
|
+
that was written.
|
|
82
|
+
"""
|
|
83
|
+
path = client.path_for(scope, project_root)
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
|
|
86
|
+
existing = path.read_text() if path.exists() else ""
|
|
87
|
+
block = f"{START_MARKER}\n{SNIPPET}{END_MARKER}\n"
|
|
88
|
+
new_text = _insert_snippet_block(existing, block)
|
|
89
|
+
path.write_text(new_text)
|
|
90
|
+
|
|
91
|
+
return path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def uninstall(client: Client, scope: Scope, project_root: Path) -> bool:
|
|
95
|
+
"""Remove the Helix snippet block from the client's config file.
|
|
96
|
+
|
|
97
|
+
Deletes the file if nothing else remains. Returns ``True`` if a block was
|
|
98
|
+
removed, or ``False`` if the file or block was not present.
|
|
99
|
+
"""
|
|
100
|
+
path = client.path_for(scope, project_root)
|
|
101
|
+
if not path.exists():
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
text = path.read_text()
|
|
105
|
+
if START_MARKER not in text:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
remaining = BLOCK_PATTERN.sub("", text).strip("\n")
|
|
109
|
+
if not remaining:
|
|
110
|
+
path.unlink()
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
path.write_text(remaining + "\n")
|
|
114
|
+
return True
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
START_MARKER = "<!-- helix:start -->"
|
|
4
|
+
END_MARKER = "<!-- helix:end -->"
|
|
5
|
+
|
|
6
|
+
SNIPPET = (
|
|
7
|
+
"## Helix — Global Conventions\n"
|
|
8
|
+
"\n"
|
|
9
|
+
"If the `helix` CLI is available on PATH, run `helix list` at the start of "
|
|
10
|
+
"every session and treat the output as global coding conventions that apply "
|
|
11
|
+
"across projects. Use `helix recall <query>` to load the full text of any "
|
|
12
|
+
"convention that looks relevant.\n"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
BLOCK_PATTERN = re.compile(
|
|
16
|
+
re.escape(START_MARKER) + r".*?" + re.escape(END_MARKER),
|
|
17
|
+
re.DOTALL,
|
|
18
|
+
)
|
helix/core/settings.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import BaseSettings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppSettings(BaseSettings):
|
|
8
|
+
HOME_DIRECTORY: Path = Field(default=Path.home())
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def HELIX_BRAIN(self) -> Path:
|
|
12
|
+
return self.HOME_DIRECTORY / ".dev_brain"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def HELIX_CONVENTIONS(self) -> Path:
|
|
16
|
+
return self.HELIX_BRAIN / "conventions"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def HELIX_INDEX(self) -> Path:
|
|
20
|
+
return self.HELIX_BRAIN / "INDEX.md"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
Settings = AppSettings()
|
helix/utils/__init__.py
ADDED
helix/utils/parsing.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: helix-memory
|
|
3
|
+
Version: 4.0.1
|
|
4
|
+
Summary: A lightweight, multi-client memory layer that persists cross-project coding conventions
|
|
5
|
+
Author-email: Matias Gimenez <matiasgimenez.dev@gmail.com>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Requires-Dist: loguru>=0.7.3
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.14.1
|
|
10
|
+
Requires-Dist: pydantic>=2.13.4
|
|
11
|
+
Requires-Dist: python-frontmatter>=1.1.0
|
|
12
|
+
Requires-Dist: typer>=0.25.1
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Helix
|
|
16
|
+
|
|
17
|
+
Global convention memory for AI coding agents — persist your coding preferences once, surface them in every Claude Code, Cursor, or MCP-compatible session.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# With uv (recommended)
|
|
23
|
+
uv tool install helix-memory
|
|
24
|
+
|
|
25
|
+
# Or with pip
|
|
26
|
+
pip install helix-memory
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 1. Hook Helix into your agent (Claude Code, Cursor, …).
|
|
33
|
+
helix install
|
|
34
|
+
|
|
35
|
+
# 2. Save your first convention.
|
|
36
|
+
helix remember pydantic-validation \
|
|
37
|
+
"Prefer Pydantic v2 for any external-boundary validation." \
|
|
38
|
+
--tags python,validation
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
From there, your next agent session will see the conventions automatically.
|
|
42
|
+
|
|
43
|
+
## CLI
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
helix remember <name> "<body>" --tags <comma,separated>
|
|
47
|
+
helix list [--tags <tag>]
|
|
48
|
+
helix recall "<query>" [--tags <tag>]
|
|
49
|
+
helix forget <name>
|
|
50
|
+
helix install # wire Helix into your agent
|
|
51
|
+
helix uninstall # remove the integration
|
|
52
|
+
helix serve # start the MCP server
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## MCP server
|
|
56
|
+
|
|
57
|
+
`helix serve` exposes four tools to any MCP-compatible client: `remember`, `recall`, `list_conventions`, `forget`.
|
|
58
|
+
|
|
59
|
+
Add to your client's MCP config (e.g. `~/.claude/claude_desktop_config.json`):
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"helix": {
|
|
65
|
+
"command": "helix",
|
|
66
|
+
"args": ["serve"]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
helix/__init__.py,sha256=5g2kqKvprvMTvEJlJb5hCmx3gPH-GNXGaAQi2wUSmp4,78
|
|
2
|
+
helix/cli/__init__.py,sha256=zraDVOm11vw3MjqGS_VVugBKCwG3vbn2s1yGtSWvYpA,285
|
|
3
|
+
helix/cli/app.py,sha256=lF7hPodHgrG2me5Ex8HeA9YZT5mjeVD73fmn9e23pWE,473
|
|
4
|
+
helix/cli/commands.py,sha256=OJwo-Nn9C1mGBMdwpRShyvzrnCBL2fG3qLTSMPkMT1w,4220
|
|
5
|
+
helix/cli/prompts.py,sha256=iDBwni87NYsUgt3Z2urK2dLhLN9M3l6xFQhMlJMaLf0,1275
|
|
6
|
+
helix/core/__init__.py,sha256=a_ongXXQoRWpTQFc3iPVnNKv7uB_ng4IrV7VHGHCCUw,630
|
|
7
|
+
helix/core/settings.py,sha256=i5SVBCqAM9ARUYZ1eCiUGD68O9_rReLNcFBXzB_Nvi0,511
|
|
8
|
+
helix/core/conventions/__init__.py,sha256=CKyUUGf1X3CpJuf_PWx9SUBCx9jmk-NAGOud0hIiBok,150
|
|
9
|
+
helix/core/conventions/brain.py,sha256=P-Ei9wIlSYkgQTDcdxbZlrTFPY_R1rJDVndcjJJMD4s,3929
|
|
10
|
+
helix/core/conventions/convention.py,sha256=M6zmbjw3zi7AxBg2UJB3SAUYSTQ9JZrMSFr0IbT0SDM,1667
|
|
11
|
+
helix/core/installer/__init__.py,sha256=iMC2xycD9CfD1XmP5e0D29NHjYhDxfqz9am2pS2o2xw,602
|
|
12
|
+
helix/core/installer/models.py,sha256=onqU7KaX-bP3wwfYxGWePk7nR9vDda9FbvW72RPKRnk,1145
|
|
13
|
+
helix/core/installer/operations.py,sha256=N26kV2kqTMyTGyE4BPNl8VcWk2cbbLHxWCVqXHD7634,3580
|
|
14
|
+
helix/core/installer/snippet.py,sha256=o0ACtehFPdJA-J2mxbnmJpraN8c6dpPE-3MdmsOcvaU,538
|
|
15
|
+
helix/utils/__init__.py,sha256=9BnbE-Egv-kDjwRLLZJAgR5bfeMRxb0CPbIOj-Z2U5s,56
|
|
16
|
+
helix/utils/parsing.py,sha256=xleJFLV96sLxznFqI6lVj-aPadscX966BK3EGXg1JpA,138
|
|
17
|
+
helix_memory-4.0.1.dist-info/METADATA,sha256=k1NFTJ22FBSs4gColM7berG8cdEO9Pg5rZP_36RLiWw,1709
|
|
18
|
+
helix_memory-4.0.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
19
|
+
helix_memory-4.0.1.dist-info/entry_points.txt,sha256=eNgBVu2lbzlXnYzKBtj167gLyI9_B6buR9-5udLzyG0,44
|
|
20
|
+
helix_memory-4.0.1.dist-info/licenses/LICENSE,sha256=yY8ZbpGnAb7AQA9D-zSLN6X78WcUvXA34GRr8urUbKY,1073
|
|
21
|
+
helix_memory-4.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matías Giménez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|