rm-contextos 0.1.0__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.
- contextos/__init__.py +6 -0
- contextos/cli/__init__.py +3 -0
- contextos/cli/commands/__init__.py +3 -0
- contextos/cli/commands/export.py +270 -0
- contextos/cli/commands/init.py +96 -0
- contextos/cli/commands/memory.py +262 -0
- contextos/cli/commands/pack.py +173 -0
- contextos/cli/commands/scan.py +98 -0
- contextos/cli/commands/task.py +153 -0
- contextos/cli/main.py +49 -0
- contextos/core/__init__.py +3 -0
- contextos/core/ast_extractor.py +234 -0
- contextos/core/compression.py +74 -0
- contextos/core/context_selector.py +641 -0
- contextos/core/dependency_graph.py +339 -0
- contextos/core/headroom_adapter.py +116 -0
- contextos/core/initializer.py +285 -0
- contextos/core/pack_builder.py +372 -0
- contextos/core/repo_index.py +528 -0
- contextos/core/safety.py +174 -0
- contextos/core/scanner.py +178 -0
- contextos/core/secret_detector.py +253 -0
- contextos/core/summarizer.py +587 -0
- contextos/core/token_counter.py +52 -0
- contextos/exporters/__init__.py +1 -0
- contextos/exporters/aider.py +62 -0
- contextos/exporters/base.py +206 -0
- contextos/exporters/claude.py +64 -0
- contextos/exporters/codex.py +62 -0
- contextos/exporters/cursor.py +62 -0
- rm_contextos-0.1.0.dist-info/METADATA +683 -0
- rm_contextos-0.1.0.dist-info/RECORD +35 -0
- rm_contextos-0.1.0.dist-info/WHEEL +4 -0
- rm_contextos-0.1.0.dist-info/entry_points.txt +2 -0
- rm_contextos-0.1.0.dist-info/licenses/LICENSE +183 -0
contextos/__init__.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""export command — generate tool-specific context files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Protocol, cast
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from contextos.core.context_selector import ContextSelection
|
|
12
|
+
from contextos.exporters.base import ExportConfig
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Export context packs for specific AI coding tools.")
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
_CONTEXTOS_DIR = ".contextos"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# Shared option types (re-declared in each sub-command for Typer compatibility)
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
_REPO_HELP = "Repository root (default: current directory)."
|
|
25
|
+
_TASK_HELP = "Task description for relevance ranking."
|
|
26
|
+
_BUDGET_HELP = "Token budget for context selection."
|
|
27
|
+
_TESTS_HELP = "Include test files in context selection."
|
|
28
|
+
_SOURCE_HELP = "Include only file summaries; never embed full source."
|
|
29
|
+
_TIMESTAMP_HELP = "Omit timestamp for reproducible output."
|
|
30
|
+
_OUT_HELP = "Also write output to this additional file path."
|
|
31
|
+
_SENSITIVE_HELP = (
|
|
32
|
+
"[DANGEROUS] Disable secret redaction. Secrets will appear in plain text. "
|
|
33
|
+
"Only use in fully isolated, private environments."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _ExporterModule(Protocol):
|
|
38
|
+
TOOL_NAME: str
|
|
39
|
+
FILENAME: str
|
|
40
|
+
|
|
41
|
+
def export(
|
|
42
|
+
self,
|
|
43
|
+
task: str,
|
|
44
|
+
repo_root: Path,
|
|
45
|
+
contextos_dir: Path,
|
|
46
|
+
*,
|
|
47
|
+
config: ExportConfig,
|
|
48
|
+
) -> tuple[str, ContextSelection]: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.callback()
|
|
52
|
+
def export_root(ctx: typer.Context) -> None: # noqa: ARG001
|
|
53
|
+
"""Generate tool-specific context files from your repo and active task."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# claude
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("claude")
|
|
62
|
+
def export_claude(
|
|
63
|
+
repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
|
|
64
|
+
task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
|
|
65
|
+
budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
|
|
66
|
+
include_tests: Annotated[
|
|
67
|
+
bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
|
|
68
|
+
] = True,
|
|
69
|
+
no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
|
|
70
|
+
no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
|
|
71
|
+
allow_sensitive: Annotated[
|
|
72
|
+
bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
|
|
73
|
+
] = False,
|
|
74
|
+
out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Generate CLAUDE_CONTEXT.md for Claude Code sessions."""
|
|
77
|
+
from contextos.exporters import claude
|
|
78
|
+
|
|
79
|
+
_run_export(
|
|
80
|
+
tool_module=claude,
|
|
81
|
+
repo=repo,
|
|
82
|
+
task=task,
|
|
83
|
+
budget=budget,
|
|
84
|
+
include_tests=include_tests,
|
|
85
|
+
no_source=no_source,
|
|
86
|
+
no_timestamp=no_timestamp,
|
|
87
|
+
allow_sensitive=allow_sensitive,
|
|
88
|
+
out=out,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# codex
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("codex")
|
|
98
|
+
def export_codex(
|
|
99
|
+
repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
|
|
100
|
+
task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
|
|
101
|
+
budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
|
|
102
|
+
include_tests: Annotated[
|
|
103
|
+
bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
|
|
104
|
+
] = True,
|
|
105
|
+
no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
|
|
106
|
+
no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
|
|
107
|
+
allow_sensitive: Annotated[
|
|
108
|
+
bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
|
|
109
|
+
] = False,
|
|
110
|
+
out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Generate CODEX_CONTEXT.md for OpenAI Codex / GPT-4 agent sessions."""
|
|
113
|
+
from contextos.exporters import codex
|
|
114
|
+
|
|
115
|
+
_run_export(
|
|
116
|
+
tool_module=codex,
|
|
117
|
+
repo=repo,
|
|
118
|
+
task=task,
|
|
119
|
+
budget=budget,
|
|
120
|
+
include_tests=include_tests,
|
|
121
|
+
no_source=no_source,
|
|
122
|
+
no_timestamp=no_timestamp,
|
|
123
|
+
allow_sensitive=allow_sensitive,
|
|
124
|
+
out=out,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# cursor
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command("cursor")
|
|
134
|
+
def export_cursor(
|
|
135
|
+
repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
|
|
136
|
+
task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
|
|
137
|
+
budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
|
|
138
|
+
include_tests: Annotated[
|
|
139
|
+
bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
|
|
140
|
+
] = True,
|
|
141
|
+
no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
|
|
142
|
+
no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
|
|
143
|
+
allow_sensitive: Annotated[
|
|
144
|
+
bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
|
|
145
|
+
] = False,
|
|
146
|
+
out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Generate CURSOR_CONTEXT.md for Cursor IDE sessions."""
|
|
149
|
+
from contextos.exporters import cursor
|
|
150
|
+
|
|
151
|
+
_run_export(
|
|
152
|
+
tool_module=cursor,
|
|
153
|
+
repo=repo,
|
|
154
|
+
task=task,
|
|
155
|
+
budget=budget,
|
|
156
|
+
include_tests=include_tests,
|
|
157
|
+
no_source=no_source,
|
|
158
|
+
no_timestamp=no_timestamp,
|
|
159
|
+
allow_sensitive=allow_sensitive,
|
|
160
|
+
out=out,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# aider
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command("aider")
|
|
170
|
+
def export_aider(
|
|
171
|
+
repo: Annotated[Path, typer.Option("--repo", "-r", help=_REPO_HELP)] = Path("."),
|
|
172
|
+
task: Annotated[str, typer.Option("--task", "-t", help=_TASK_HELP)] = ..., # type: ignore[assignment]
|
|
173
|
+
budget: Annotated[int, typer.Option("--budget", "-b", help=_BUDGET_HELP)] = 8000,
|
|
174
|
+
include_tests: Annotated[
|
|
175
|
+
bool, typer.Option("--include-tests/--no-tests", help=_TESTS_HELP)
|
|
176
|
+
] = True,
|
|
177
|
+
no_source: Annotated[bool, typer.Option("--no-source", help=_SOURCE_HELP)] = False,
|
|
178
|
+
no_timestamp: Annotated[bool, typer.Option("--no-timestamp", help=_TIMESTAMP_HELP)] = False,
|
|
179
|
+
allow_sensitive: Annotated[
|
|
180
|
+
bool, typer.Option("--allow-sensitive", help=_SENSITIVE_HELP)
|
|
181
|
+
] = False,
|
|
182
|
+
out: Annotated[Path | None, typer.Option("--out", "-o", help=_OUT_HELP)] = None,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Generate AIDER_CONTEXT.md for Aider pair-programming sessions."""
|
|
185
|
+
from contextos.exporters import aider
|
|
186
|
+
|
|
187
|
+
_run_export(
|
|
188
|
+
tool_module=aider,
|
|
189
|
+
repo=repo,
|
|
190
|
+
task=task,
|
|
191
|
+
budget=budget,
|
|
192
|
+
include_tests=include_tests,
|
|
193
|
+
no_source=no_source,
|
|
194
|
+
no_timestamp=no_timestamp,
|
|
195
|
+
allow_sensitive=allow_sensitive,
|
|
196
|
+
out=out,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
# Shared runner (called by all sub-commands)
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _run_export(
|
|
206
|
+
*,
|
|
207
|
+
tool_module: object,
|
|
208
|
+
repo: Path,
|
|
209
|
+
task: str,
|
|
210
|
+
budget: int,
|
|
211
|
+
include_tests: bool,
|
|
212
|
+
no_source: bool,
|
|
213
|
+
no_timestamp: bool,
|
|
214
|
+
allow_sensitive: bool = False,
|
|
215
|
+
out: Path | None,
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Validate inputs, call the tool exporter, and print a summary."""
|
|
218
|
+
from contextos.exporters.base import ExportConfig
|
|
219
|
+
|
|
220
|
+
root = repo.resolve()
|
|
221
|
+
if not root.is_dir():
|
|
222
|
+
console.print(f"[red]Error:[/red] {root} is not a directory.")
|
|
223
|
+
raise typer.Exit(code=1)
|
|
224
|
+
|
|
225
|
+
if budget <= 0:
|
|
226
|
+
console.print("[red]Error:[/red] --budget must be a positive integer.")
|
|
227
|
+
raise typer.Exit(code=1)
|
|
228
|
+
|
|
229
|
+
contextos_dir = root / _CONTEXTOS_DIR
|
|
230
|
+
exporter = cast(_ExporterModule, tool_module)
|
|
231
|
+
tool_name = getattr(exporter, "TOOL_NAME", "Unknown")
|
|
232
|
+
filename = getattr(exporter, "FILENAME", "CONTEXT.md")
|
|
233
|
+
|
|
234
|
+
console.print(f"[bold]Exporting[/bold] context for [cyan]{tool_name}[/cyan]")
|
|
235
|
+
console.print(f" Repo : {root}")
|
|
236
|
+
console.print(f" Task : {task}")
|
|
237
|
+
console.print(f" Budget : {budget:,} tokens")
|
|
238
|
+
if no_source:
|
|
239
|
+
console.print(" Source : [dim]summaries only[/dim]")
|
|
240
|
+
if not include_tests:
|
|
241
|
+
console.print(" Tests : [dim]excluded[/dim]")
|
|
242
|
+
if allow_sensitive:
|
|
243
|
+
console.print(" [bold red]⚠ --allow-sensitive: secret redaction DISABLED[/bold red]")
|
|
244
|
+
|
|
245
|
+
cfg = ExportConfig(
|
|
246
|
+
budget=budget,
|
|
247
|
+
include_tests=include_tests,
|
|
248
|
+
no_source=no_source,
|
|
249
|
+
add_timestamp=not no_timestamp,
|
|
250
|
+
allow_sensitive=allow_sensitive,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
content, selection = exporter.export(task, root, contextos_dir, config=cfg)
|
|
254
|
+
|
|
255
|
+
if selection.secret_warnings and not allow_sensitive:
|
|
256
|
+
console.print(
|
|
257
|
+
f" [yellow]⚠ {len(selection.secret_warnings)} secret(s) detected and "
|
|
258
|
+
f"redacted with [REDACTED_*][/yellow]"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
default_out = contextos_dir / filename
|
|
262
|
+
console.print(
|
|
263
|
+
f"\n[green]✓[/green] Wrote [bold]{filename}[/bold] "
|
|
264
|
+
f"({len(selection.selected)} files, ~{selection.used_tokens:,} tokens)"
|
|
265
|
+
)
|
|
266
|
+
console.print(f" Path: {default_out}")
|
|
267
|
+
|
|
268
|
+
if out is not None:
|
|
269
|
+
out.write_text(content, encoding="utf-8")
|
|
270
|
+
console.print(f"[green]✓[/green] Also written to [bold]{out}[/bold]")
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""init command — initialize the .contextos/ project directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from contextos.core import initializer
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Initialize the .contextos/ directory in a project.")
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
_STATUS_STYLE: dict[str, str] = {
|
|
18
|
+
"created": "green",
|
|
19
|
+
"overwritten": "yellow",
|
|
20
|
+
"skipped": "dim",
|
|
21
|
+
"error": "red",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_STATUS_ICON: dict[str, str] = {
|
|
25
|
+
"created": "✓",
|
|
26
|
+
"overwritten": "↺",
|
|
27
|
+
"skipped": "–",
|
|
28
|
+
"error": "✗",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def init_command(
|
|
33
|
+
directory: Annotated[Path, typer.Argument(help="Project directory to initialize.")] = Path("."),
|
|
34
|
+
force: Annotated[
|
|
35
|
+
bool, typer.Option("--force", "-f", help="Overwrite all existing files.")
|
|
36
|
+
] = False,
|
|
37
|
+
quiet: Annotated[
|
|
38
|
+
bool, typer.Option("--quiet", "-q", help="Suppress file-by-file output.")
|
|
39
|
+
] = False,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Create the .contextos/ directory with template files.
|
|
42
|
+
|
|
43
|
+
Safe to run multiple times — existing memory files are skipped unless
|
|
44
|
+
--force is passed. Computed files (file_summaries.json, etc.) are also
|
|
45
|
+
skipped by init; they are overwritten by `scan` and `pack`.
|
|
46
|
+
"""
|
|
47
|
+
root = directory.resolve()
|
|
48
|
+
|
|
49
|
+
if not root.exists():
|
|
50
|
+
console.print(f"[red]Error:[/red] directory does not exist: {root}")
|
|
51
|
+
raise typer.Exit(code=1)
|
|
52
|
+
|
|
53
|
+
result = initializer.run(root, force=force)
|
|
54
|
+
|
|
55
|
+
if not quiet:
|
|
56
|
+
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 1))
|
|
57
|
+
table.add_column("Status", width=2)
|
|
58
|
+
table.add_column("File")
|
|
59
|
+
table.add_column("Note", style="dim")
|
|
60
|
+
|
|
61
|
+
for f in result.files:
|
|
62
|
+
icon = _STATUS_ICON.get(f.status, "?")
|
|
63
|
+
style = _STATUS_STYLE.get(f.status, "")
|
|
64
|
+
note = f.message if f.status == "error" else ""
|
|
65
|
+
table.add_row(
|
|
66
|
+
f"[{style}]{icon}[/{style}]",
|
|
67
|
+
f"[{style}].contextos/{f.name}[/{style}]",
|
|
68
|
+
note,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
console.print(table)
|
|
72
|
+
console.print()
|
|
73
|
+
|
|
74
|
+
created = len(result.created)
|
|
75
|
+
skipped = len(result.skipped)
|
|
76
|
+
overwritten = len(result.overwritten)
|
|
77
|
+
errors = len(result.errors)
|
|
78
|
+
|
|
79
|
+
parts: list[str] = []
|
|
80
|
+
if created:
|
|
81
|
+
parts.append(f"[green]{created} created[/green]")
|
|
82
|
+
if overwritten:
|
|
83
|
+
parts.append(f"[yellow]{overwritten} overwritten[/yellow]")
|
|
84
|
+
if skipped:
|
|
85
|
+
parts.append(f"[dim]{skipped} skipped[/dim]")
|
|
86
|
+
if errors:
|
|
87
|
+
parts.append(f"[red]{errors} error(s)[/red]")
|
|
88
|
+
|
|
89
|
+
summary = ", ".join(parts) if parts else "nothing to do"
|
|
90
|
+
console.print(f"[bold]{result.contextos_dir}[/bold] {summary}")
|
|
91
|
+
|
|
92
|
+
if errors:
|
|
93
|
+
raise typer.Exit(code=1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
app.command()(init_command)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""memory command — manage .contextos/MEMORY.md and .contextos/DECISIONS.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Manage project memory and decision log in .contextos/.")
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
_CONTEXTOS_DIR = ".contextos"
|
|
18
|
+
_MEMORY_FILE = "MEMORY.md"
|
|
19
|
+
_DECISIONS_FILE = "DECISIONS.md"
|
|
20
|
+
|
|
21
|
+
# Patterns that suggest embedded secrets — reject notes containing these.
|
|
22
|
+
# Matches "keyword=<value>" or "keyword: <value>" (not bare mentions of the word).
|
|
23
|
+
_SECRET_RE: re.Pattern[str] = re.compile(
|
|
24
|
+
r"(?:password|secret|api[_\-.]?key|access[_\-.]?token|bearer|private[_\-.]?key)"
|
|
25
|
+
r"\s*[=:]\s*\S{4,}",
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
# Long hex strings that look like raw API keys or hashes.
|
|
29
|
+
_HEX_RE: re.Pattern[str] = re.compile(r"[0-9a-fA-F]{40,}")
|
|
30
|
+
# Base64 blobs (common in JWT / bearer tokens).
|
|
31
|
+
_B64_RE: re.Pattern[str] = re.compile(r"[A-Za-z0-9+/]{60,}={0,2}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Sub-command group
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def memory_root(ctx: typer.Context) -> None: # noqa: ARG001
|
|
41
|
+
"""Manage .contextos/MEMORY.md and .contextos/DECISIONS.md."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Commands
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command("add")
|
|
50
|
+
def memory_add(
|
|
51
|
+
note: Annotated[str, typer.Argument(help="Note text to append to MEMORY.md.")],
|
|
52
|
+
repo: Annotated[
|
|
53
|
+
Path,
|
|
54
|
+
typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
|
|
55
|
+
] = Path("."),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Append a timestamped note to .contextos/MEMORY.md."""
|
|
58
|
+
_impl_add(note, repo.resolve())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command("update")
|
|
62
|
+
def memory_update(
|
|
63
|
+
note: Annotated[str, typer.Argument(help="Note text to append to MEMORY.md.")],
|
|
64
|
+
repo: Annotated[
|
|
65
|
+
Path,
|
|
66
|
+
typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
|
|
67
|
+
] = Path("."),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Append a timestamped note to .contextos/MEMORY.md (alias for add)."""
|
|
70
|
+
_impl_add(note, repo.resolve())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("decision")
|
|
74
|
+
def memory_decision(
|
|
75
|
+
text: Annotated[str, typer.Argument(help="Decision text to record.")],
|
|
76
|
+
repo: Annotated[
|
|
77
|
+
Path,
|
|
78
|
+
typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
|
|
79
|
+
] = Path("."),
|
|
80
|
+
status: Annotated[
|
|
81
|
+
str,
|
|
82
|
+
typer.Option("--status", "-s", help='Decision status (default: "accepted").'),
|
|
83
|
+
] = "accepted",
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Append a timestamped decision to .contextos/DECISIONS.md."""
|
|
86
|
+
_impl_decision(text, status, repo.resolve())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.command("list")
|
|
90
|
+
def memory_list(
|
|
91
|
+
repo: Annotated[
|
|
92
|
+
Path,
|
|
93
|
+
typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
|
|
94
|
+
] = Path("."),
|
|
95
|
+
show_decisions: Annotated[
|
|
96
|
+
bool,
|
|
97
|
+
typer.Option("--decisions/--no-decisions", help="Also show DECISIONS.md."),
|
|
98
|
+
] = True,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Display .contextos/MEMORY.md and optionally DECISIONS.md."""
|
|
101
|
+
_impl_list(repo.resolve(), show_decisions=show_decisions)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command("compact")
|
|
105
|
+
def memory_compact(
|
|
106
|
+
repo: Annotated[ # noqa: ARG001
|
|
107
|
+
Path,
|
|
108
|
+
typer.Option("--repo", "-r", help="Repository root (default: current directory)."),
|
|
109
|
+
] = Path("."),
|
|
110
|
+
) -> None:
|
|
111
|
+
"""[Placeholder] Compact memory via LLM — not yet implemented."""
|
|
112
|
+
console.print("[yellow]Compaction is not yet implemented.[/yellow]")
|
|
113
|
+
console.print(
|
|
114
|
+
"To compact manually: edit [bold].contextos/MEMORY.md[/bold] and remove outdated entries."
|
|
115
|
+
)
|
|
116
|
+
console.print("Future versions will compress notes into concise summaries using an LLM.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Implementation helpers (also imported directly in tests)
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _now_iso() -> str:
|
|
125
|
+
return datetime.now(tz=UTC).isoformat(timespec="seconds")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _memory_path(root: Path) -> Path:
|
|
129
|
+
return root / _CONTEXTOS_DIR / _MEMORY_FILE
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _decisions_path(root: Path) -> Path:
|
|
133
|
+
return root / _CONTEXTOS_DIR / _DECISIONS_FILE
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _contains_secret(text: str) -> bool:
|
|
137
|
+
"""Return True if *text* appears to embed a secret or credential value."""
|
|
138
|
+
return bool(_SECRET_RE.search(text) or _HEX_RE.search(text) or _B64_RE.search(text))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _impl_add(note: str, root: Path) -> None:
|
|
142
|
+
if _contains_secret(note):
|
|
143
|
+
console.print("[red]Error:[/red] Note appears to contain a secret or credential value.")
|
|
144
|
+
console.print("Remove secrets before saving to memory.")
|
|
145
|
+
raise typer.Exit(code=1)
|
|
146
|
+
|
|
147
|
+
path = _memory_path(root)
|
|
148
|
+
_ensure_memory(path)
|
|
149
|
+
ts = _now_iso()
|
|
150
|
+
entry = f"- **{ts}** — {note}\n"
|
|
151
|
+
_append_to_notes(path, entry)
|
|
152
|
+
console.print(f"[green]✓[/green] Note appended to [bold]{_CONTEXTOS_DIR}/{_MEMORY_FILE}[/bold]")
|
|
153
|
+
console.print(f" Set : {ts}")
|
|
154
|
+
console.print(f" Note: {note}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _impl_decision(text: str, status: str, root: Path) -> None:
|
|
158
|
+
if _contains_secret(text):
|
|
159
|
+
console.print(
|
|
160
|
+
"[red]Error:[/red] Decision text appears to contain a secret or credential value."
|
|
161
|
+
)
|
|
162
|
+
raise typer.Exit(code=1)
|
|
163
|
+
|
|
164
|
+
path = _decisions_path(root)
|
|
165
|
+
_ensure_decisions(path)
|
|
166
|
+
ts = _now_iso()
|
|
167
|
+
date = ts[:10]
|
|
168
|
+
entry = (
|
|
169
|
+
"\n---\n\n"
|
|
170
|
+
f"### [{date}] Decision\n\n"
|
|
171
|
+
f"**Status:** {status}\n\n"
|
|
172
|
+
f"**Decision:** {text}\n\n"
|
|
173
|
+
f"**Logged:** {ts}\n"
|
|
174
|
+
)
|
|
175
|
+
_append_raw(path, entry)
|
|
176
|
+
console.print(
|
|
177
|
+
f"[green]✓[/green] Decision appended to [bold]{_CONTEXTOS_DIR}/{_DECISIONS_FILE}[/bold]"
|
|
178
|
+
)
|
|
179
|
+
console.print(f" Status : {status}")
|
|
180
|
+
console.print(f" Decision : {text}")
|
|
181
|
+
console.print(f" Logged : {ts}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _impl_list(root: Path, *, show_decisions: bool = True) -> None:
|
|
185
|
+
mem = _memory_path(root)
|
|
186
|
+
dec = _decisions_path(root)
|
|
187
|
+
found_any = False
|
|
188
|
+
|
|
189
|
+
if mem.exists():
|
|
190
|
+
found_any = True
|
|
191
|
+
console.print(f"\n[bold cyan]── {_MEMORY_FILE} ──[/bold cyan]")
|
|
192
|
+
console.print(Markdown(mem.read_text(encoding="utf-8")))
|
|
193
|
+
|
|
194
|
+
if show_decisions and dec.exists():
|
|
195
|
+
found_any = True
|
|
196
|
+
console.print(f"\n[bold cyan]── {_DECISIONS_FILE} ──[/bold cyan]")
|
|
197
|
+
console.print(Markdown(dec.read_text(encoding="utf-8")))
|
|
198
|
+
|
|
199
|
+
if not found_any:
|
|
200
|
+
console.print(
|
|
201
|
+
"[yellow]No memory files found.[/yellow] "
|
|
202
|
+
"Run `contextos init` or `contextos memory add` first."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# File helpers
|
|
208
|
+
# ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _ensure_memory(path: Path) -> None:
|
|
212
|
+
"""Create MEMORY.md with a Notes section if it doesn't exist."""
|
|
213
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
if not path.exists():
|
|
215
|
+
path.write_text(
|
|
216
|
+
"# Project Memory\n\n"
|
|
217
|
+
"> Persistent notes managed by ContextOS. Append-only recommended.\n\n"
|
|
218
|
+
"## Notes\n\n",
|
|
219
|
+
encoding="utf-8",
|
|
220
|
+
)
|
|
221
|
+
elif "## Notes" not in path.read_text(encoding="utf-8"):
|
|
222
|
+
_append_raw(path, "\n## Notes\n\n")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _ensure_decisions(path: Path) -> None:
|
|
226
|
+
"""Create DECISIONS.md with a header if it doesn't exist."""
|
|
227
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
if not path.exists():
|
|
229
|
+
path.write_text(
|
|
230
|
+
"# Decision Log\n\n> Architectural and design decisions. Append-only recommended.\n\n",
|
|
231
|
+
encoding="utf-8",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _append_to_notes(path: Path, entry: str) -> None:
|
|
236
|
+
"""Append *entry* inside the ## Notes section (before next ## or EOF)."""
|
|
237
|
+
text = path.read_text(encoding="utf-8")
|
|
238
|
+
section = "## Notes"
|
|
239
|
+
if section not in text:
|
|
240
|
+
_append_raw(path, f"\n{section}\n\n{entry}")
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
idx = text.index(section)
|
|
244
|
+
after_section = text[idx + len(section) :]
|
|
245
|
+
# Find next top-level section heading after ## Notes
|
|
246
|
+
next_h2 = re.search(r"\n##\s", after_section)
|
|
247
|
+
if next_h2:
|
|
248
|
+
insert_pos = idx + len(section) + next_h2.start()
|
|
249
|
+
text = text[:insert_pos] + "\n" + entry + text[insert_pos:]
|
|
250
|
+
else:
|
|
251
|
+
if not text.endswith("\n"):
|
|
252
|
+
text += "\n"
|
|
253
|
+
text += entry
|
|
254
|
+
path.write_text(text, encoding="utf-8")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _append_raw(path: Path, text: str) -> None:
|
|
258
|
+
"""Append *text* verbatim to the end of *path*."""
|
|
259
|
+
existing = path.read_text(encoding="utf-8")
|
|
260
|
+
if existing and not existing.endswith("\n"):
|
|
261
|
+
existing += "\n"
|
|
262
|
+
path.write_text(existing + text, encoding="utf-8")
|