klyd 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.
- klyd/__init__.py +10 -0
- klyd/cli.py +454 -0
- klyd/config.py +44 -0
- klyd/db.py +124 -0
- klyd/extractor.py +129 -0
- klyd/hooks.py +40 -0
- klyd/injector.py +18 -0
- klyd-0.1.0.dist-info/METADATA +61 -0
- klyd-0.1.0.dist-info/RECORD +13 -0
- klyd-0.1.0.dist-info/WHEEL +5 -0
- klyd-0.1.0.dist-info/entry_points.txt +2 -0
- klyd-0.1.0.dist-info/licenses/LICENSE +21 -0
- klyd-0.1.0.dist-info/top_level.txt +1 -0
klyd/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
frame = sys._getframe()
|
|
4
|
+
while frame:
|
|
5
|
+
if frame.f_code.co_name == '_get_module_details' and frame.f_locals.get('mod_name') == 'klyd.__main__':
|
|
6
|
+
from klyd.cli import cli
|
|
7
|
+
sys.argv[0] = 'python -m klyd'
|
|
8
|
+
cli()
|
|
9
|
+
sys.exit(0)
|
|
10
|
+
frame = frame.f_back
|
klyd/cli.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import subprocess
|
|
3
|
+
import json
|
|
4
|
+
import traceback
|
|
5
|
+
from .hooks import install_hooks
|
|
6
|
+
from .config import init_config, set_config, get_config, get_all_config
|
|
7
|
+
from .db import get_schema_path
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.rule import Rule
|
|
14
|
+
from rich.columns import Columns
|
|
15
|
+
from rich.prompt import Prompt
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
import rich.box
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
class KlydGroup(click.Group):
|
|
22
|
+
def format_help(self, ctx, formatter):
|
|
23
|
+
console.print()
|
|
24
|
+
console.print()
|
|
25
|
+
console.print(r"[yellow bold]888 / 888 Y88b / 888~-_ [/yellow bold]")
|
|
26
|
+
console.print(r"[yellow bold]888 / 888 Y88b / 888 \ [/yellow bold]")
|
|
27
|
+
console.print(r"[yellow bold]888/\ 888 Y88b/ 888 | [/yellow bold]")
|
|
28
|
+
console.print(r"[yellow bold]888 \ 888 Y8Y 888 | [/yellow bold]")
|
|
29
|
+
console.print(r"[yellow bold]888 \ 888 Y 888 / [/yellow bold]")
|
|
30
|
+
console.print(r"[yellow bold]888 \ 888____ / 888_-~ [/yellow bold]")
|
|
31
|
+
console.print()
|
|
32
|
+
console.print(r"[dim] (An open-source project, not affiliated with the Klyd SaaS)[/dim]")
|
|
33
|
+
console.print()
|
|
34
|
+
console.print()
|
|
35
|
+
super().format_help(ctx, formatter)
|
|
36
|
+
|
|
37
|
+
@click.group(cls=KlydGroup)
|
|
38
|
+
def cli():
|
|
39
|
+
"""klyd: a CLI tool that wraps coding agents via git hooks to inject architectural memory."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def echo_brand(msg, bold=False):
|
|
43
|
+
console.print(f"[cyan bold]klyd[/cyan bold] | {msg}")
|
|
44
|
+
|
|
45
|
+
@cli.command()
|
|
46
|
+
def init():
|
|
47
|
+
"""Initialize klyd in the current git repository."""
|
|
48
|
+
console.print()
|
|
49
|
+
console.print()
|
|
50
|
+
console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888. ,8' 8 888888888o. [/green bold]")
|
|
51
|
+
console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888. ,8' 8 8888 `^888. [/green bold]")
|
|
52
|
+
console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888. ,8' 8 8888 `88. [/green bold]")
|
|
53
|
+
console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`8888.,8' 8 8888 `88 [/green bold]")
|
|
54
|
+
console.print(r"[green bold]8 8888 ,88' 8 8888 `8.`88888' 8 8888 88 [/green bold]")
|
|
55
|
+
console.print(r"[green bold]8 8888 88' 8 8888 `8. 8888 8 8888 88 [/green bold]")
|
|
56
|
+
console.print(r"[green bold]8 888888< 8 8888 `8 8888 8 8888 ,88 [/green bold]")
|
|
57
|
+
console.print(r"[green bold]8 8888 `Y8. 8 8888 8 8888 8 8888 ,88' [/green bold]")
|
|
58
|
+
console.print(r"[green bold]8 8888 `Y8. 8 8888 8 8888 8 8888 ,o88P' [/green bold]")
|
|
59
|
+
console.print(r"[green bold]8 8888 `Y8. 8 888888888888 8 8888 8 888888888P' [/green bold]")
|
|
60
|
+
console.print()
|
|
61
|
+
console.print(r" [dim](An open-source project, not affiliated with the Klyd SaaS)[/dim]")
|
|
62
|
+
console.print()
|
|
63
|
+
console.print()
|
|
64
|
+
try:
|
|
65
|
+
with console.status("[bold cyan]Creating .klyd directory, database, and hooks...[/bold cyan]", spinner="dots12"):
|
|
66
|
+
install_hooks()
|
|
67
|
+
init_config()
|
|
68
|
+
klyd_dir = Path('.klyd')
|
|
69
|
+
db_path = klyd_dir / 'memory.db'
|
|
70
|
+
from .db import init_db
|
|
71
|
+
init_db(str(db_path))
|
|
72
|
+
|
|
73
|
+
console.print(Panel(
|
|
74
|
+
"Klyd harness initialised in [cyan].klyd[/cyan]\n\n[dim]Installed git hooks for automatic extraction.[/dim]\n[dim]Errors are logged to .klyd/errors.log[/dim]",
|
|
75
|
+
title="[bold green]SUCCESS[/bold green]", border_style="green", padding=(1, 2)
|
|
76
|
+
))
|
|
77
|
+
except Exception as e:
|
|
78
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
79
|
+
raise click.Abort()
|
|
80
|
+
|
|
81
|
+
@cli.command()
|
|
82
|
+
@click.option('--api-key', help='Anthropic API key')
|
|
83
|
+
@click.option('--openai-key', help='OpenAI API key')
|
|
84
|
+
@click.option('--openrouter-key', help='OpenRouter API key')
|
|
85
|
+
@click.option('--gemini-key', help='Gemini API key')
|
|
86
|
+
@click.option('--groq-key', help='Groq API key')
|
|
87
|
+
@click.option('--model', help='Model to use (default: claude-sonnet-4-6)')
|
|
88
|
+
@click.option('--show', is_flag=True, help='Show current configuration')
|
|
89
|
+
def config(api_key, openai_key, openrouter_key, gemini_key, groq_key, model, show):
|
|
90
|
+
"""Set klyd configuration."""
|
|
91
|
+
if show:
|
|
92
|
+
cfg = get_all_config()
|
|
93
|
+
table = Table(title="[bold cyan]klyd Configuration[/bold cyan]", box=rich.box.SIMPLE, header_style="bold cyan")
|
|
94
|
+
table.add_column("Key", style="cyan")
|
|
95
|
+
table.add_column("Value", style="green")
|
|
96
|
+
if not cfg:
|
|
97
|
+
table.add_row("[dim]No configuration set[/dim]", "[dim]Run: kl config --api-key ...[/dim]")
|
|
98
|
+
else:
|
|
99
|
+
for k, v in cfg.items():
|
|
100
|
+
if 'key' in k and v:
|
|
101
|
+
v = v[:4] + '*' * (len(v) - 8) + v[-4:] if len(v) > 8 else '*' * len(v)
|
|
102
|
+
table.add_row(k, v)
|
|
103
|
+
console.print(table)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
changes = False
|
|
107
|
+
with console.status("[bold cyan]Saving configuration...[/bold cyan]", spinner="dots12"):
|
|
108
|
+
if api_key:
|
|
109
|
+
set_config('api_key', api_key)
|
|
110
|
+
changes = True
|
|
111
|
+
if openai_key:
|
|
112
|
+
set_config('openai_key', openai_key)
|
|
113
|
+
changes = True
|
|
114
|
+
if openrouter_key:
|
|
115
|
+
set_config('openrouter_key', openrouter_key)
|
|
116
|
+
changes = True
|
|
117
|
+
if gemini_key:
|
|
118
|
+
set_config('gemini_key', gemini_key)
|
|
119
|
+
changes = True
|
|
120
|
+
if groq_key:
|
|
121
|
+
set_config('groq_key', groq_key)
|
|
122
|
+
changes = True
|
|
123
|
+
if model:
|
|
124
|
+
set_config('model', model)
|
|
125
|
+
changes = True
|
|
126
|
+
|
|
127
|
+
if changes:
|
|
128
|
+
console.print(Panel("[bold green]Configuration saved successfully[/bold green]", title="[bold green]DONE[/bold green]", border_style="green", expand=False))
|
|
129
|
+
else:
|
|
130
|
+
console.print("[yellow]Usage:[/yellow] kl config --api-key ... --openai-key ... --model ...\nOr use --show to display current configuration.")
|
|
131
|
+
|
|
132
|
+
@cli.command(context_settings={"ignore_unknown_options": True})
|
|
133
|
+
@click.option('--no-inject', is_flag=True, help='Skip generating injection file')
|
|
134
|
+
@click.argument('cmd', nargs=-1, type=click.UNPROCESSED)
|
|
135
|
+
def run(no_inject, cmd):
|
|
136
|
+
"""Run an agent with injected architectural memory."""
|
|
137
|
+
if not cmd:
|
|
138
|
+
console.print("Usage: kl run <agent> [args...]")
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
klyd_dir = Path('.klyd')
|
|
142
|
+
inj_path = klyd_dir / 'injection.txt'
|
|
143
|
+
|
|
144
|
+
if not no_inject:
|
|
145
|
+
with console.status("[bold cyan]Preparing injection context...[/bold cyan]", spinner="dots12"):
|
|
146
|
+
try:
|
|
147
|
+
ctx = click.get_current_context()
|
|
148
|
+
ctx.invoke(prepare_injection)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
run_cmd = list(cmd)
|
|
153
|
+
agent_name = run_cmd[0].lower()
|
|
154
|
+
|
|
155
|
+
if inj_path.exists() and inj_path.stat().st_size > 0:
|
|
156
|
+
if agent_name == 'aider':
|
|
157
|
+
run_cmd.extend(['--message-file', str(inj_path)])
|
|
158
|
+
elif agent_name == 'opencode':
|
|
159
|
+
run_cmd.extend(['-m', inj_path.read_text()])
|
|
160
|
+
|
|
161
|
+
console.print(Panel(f"[bold cyan]Launching[/bold cyan] {agent_name}...", title="[bold blue]AGENT START[/bold blue]", border_style="blue", expand=False))
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
subprocess.run(run_cmd)
|
|
165
|
+
console.print(Panel("[bold green]Agent session ended[/bold green]", title="[bold green]DONE[/bold green]", border_style="green", expand=False))
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
console.print(f"[red]Command not found: {cmd[0]}[/red]")
|
|
168
|
+
|
|
169
|
+
@cli.command()
|
|
170
|
+
def extract_commit():
|
|
171
|
+
"""Extract decisions from the last commit."""
|
|
172
|
+
from .extractor import extract_decisions
|
|
173
|
+
from .db import get_decisions_for_files, store_decision, reinforce_decision, flag_decision
|
|
174
|
+
|
|
175
|
+
klyd_dir = Path('.klyd')
|
|
176
|
+
if not (klyd_dir / 'memory.db').exists():
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
try:
|
|
181
|
+
diff = subprocess.check_output(['git', 'diff', 'HEAD~1', 'HEAD'], text=True)
|
|
182
|
+
msg = subprocess.check_output(['git', 'log', '-1', '--format=%B'], text=True)
|
|
183
|
+
files_out = subprocess.check_output(['git', 'diff', '--name-only', 'HEAD~1', 'HEAD'], text=True)
|
|
184
|
+
files = [f for f in files_out.strip().split('\n') if f]
|
|
185
|
+
commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
|
|
186
|
+
except subprocess.CalledProcessError:
|
|
187
|
+
diff = subprocess.check_output(['git', 'show', 'HEAD'], text=True)
|
|
188
|
+
msg = subprocess.check_output(['git', 'log', '-1', '--format=%B'], text=True)
|
|
189
|
+
files_out = subprocess.check_output(['git', 'show', '--name-only', '--format=', 'HEAD'], text=True)
|
|
190
|
+
files = [f for f in files_out.strip().split('\n') if f]
|
|
191
|
+
commit_hash = subprocess.check_output(['git', 'rev-parse', 'HEAD'], text=True).strip()
|
|
192
|
+
|
|
193
|
+
if not files:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
with console.status("[bold cyan]Extracting architectural decisions via LLM...[/bold cyan]", spinner="dots12"):
|
|
197
|
+
db_path = str(klyd_dir / 'memory.db')
|
|
198
|
+
existing = get_decisions_for_files(db_path, files, top_k=20)
|
|
199
|
+
existing_json = json.dumps(existing, indent=2)
|
|
200
|
+
|
|
201
|
+
config_data = get_all_config()
|
|
202
|
+
model = config_data.get('model', 'claude-sonnet-4-6')
|
|
203
|
+
|
|
204
|
+
decisions = extract_decisions(diff, msg, existing_json, config_data, model)
|
|
205
|
+
|
|
206
|
+
new_count = 0
|
|
207
|
+
reinforced_count = 0
|
|
208
|
+
|
|
209
|
+
for d in decisions:
|
|
210
|
+
event = d.get('event_type')
|
|
211
|
+
|
|
212
|
+
if event == 'REINFORCE':
|
|
213
|
+
match = next((e for e in existing if e['module'] == d['module'] and e['decision'] == d['decision']), None)
|
|
214
|
+
if match:
|
|
215
|
+
reinforce_decision(db_path, match['id'], commit_hash)
|
|
216
|
+
reinforced_count += 1
|
|
217
|
+
else:
|
|
218
|
+
d['event_type'] = 'NEW'
|
|
219
|
+
store_decision(db_path, d)
|
|
220
|
+
new_count += 1
|
|
221
|
+
elif event == 'CONTRADICT':
|
|
222
|
+
did = store_decision(db_path, d)
|
|
223
|
+
flag_decision(db_path, did)
|
|
224
|
+
new_count += 1
|
|
225
|
+
else:
|
|
226
|
+
store_decision(db_path, d)
|
|
227
|
+
new_count += 1
|
|
228
|
+
|
|
229
|
+
if new_count > 0 or reinforced_count > 0:
|
|
230
|
+
console.print(Panel(
|
|
231
|
+
f"[bold green]{new_count}[/bold green] new decisions extracted\n[bold yellow]{reinforced_count}[/bold yellow] decisions reinforced",
|
|
232
|
+
title="[bold cyan]EXTRACTION COMPLETE[/bold cyan]", border_style="cyan", expand=False
|
|
233
|
+
))
|
|
234
|
+
else:
|
|
235
|
+
console.print(Panel(
|
|
236
|
+
"[dim]No architectural decisions found in this commit[/dim]",
|
|
237
|
+
title="[dim]EXTRACTION COMPLETE[/dim]", border_style="dim", expand=False
|
|
238
|
+
))
|
|
239
|
+
|
|
240
|
+
except Exception as e:
|
|
241
|
+
err_log = klyd_dir / 'errors.log'
|
|
242
|
+
with open(err_log, 'a') as f:
|
|
243
|
+
f.write(f"Error extracting commit:\n{traceback.format_exc()}\n")
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
@cli.command()
|
|
247
|
+
def prepare_injection():
|
|
248
|
+
"""Prepare injection file for agent sessions."""
|
|
249
|
+
from .db import get_decisions_for_files
|
|
250
|
+
from .injector import format_injection
|
|
251
|
+
|
|
252
|
+
klyd_dir = Path('.klyd')
|
|
253
|
+
if not (klyd_dir / 'memory.db').exists():
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
with console.status("[bold cyan]Preparing injection context...[/bold cyan]", spinner="dots12"):
|
|
258
|
+
files_out = subprocess.check_output(['git', 'diff', '--cached', '--name-only'], text=True)
|
|
259
|
+
files = [f for f in files_out.strip().split('\n') if f]
|
|
260
|
+
|
|
261
|
+
if not files:
|
|
262
|
+
with open(klyd_dir / 'injection.txt', 'w') as f:
|
|
263
|
+
f.write('')
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
db_path = str(klyd_dir / 'memory.db')
|
|
267
|
+
decisions = get_decisions_for_files(db_path, files, top_k=20)
|
|
268
|
+
|
|
269
|
+
injection = format_injection(decisions)
|
|
270
|
+
with open(klyd_dir / 'injection.txt', 'w') as f:
|
|
271
|
+
f.write(injection)
|
|
272
|
+
|
|
273
|
+
console.print(Panel("[green]Injection file ready at .klyd/injection.txt[/green]", border_style="green", expand=False))
|
|
274
|
+
|
|
275
|
+
except Exception as e:
|
|
276
|
+
with open(klyd_dir / 'errors.log', 'a') as f:
|
|
277
|
+
f.write(f"Error preparing injection:\n{traceback.format_exc()}\n")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
@cli.command()
|
|
281
|
+
def review():
|
|
282
|
+
"""Review flagged conflicting decisions."""
|
|
283
|
+
from .db import get_flagged_decisions, get_active_decisions_by_module, resolve_decision
|
|
284
|
+
|
|
285
|
+
klyd_dir = Path('.klyd')
|
|
286
|
+
db_path = klyd_dir / 'memory.db'
|
|
287
|
+
if not db_path.exists():
|
|
288
|
+
console.print("[bold red]klyd is not initialized. Run `kl init`.[/bold red]")
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
db_str = str(db_path)
|
|
292
|
+
flagged = get_flagged_decisions(db_str)
|
|
293
|
+
|
|
294
|
+
if not flagged:
|
|
295
|
+
console.print(Panel(
|
|
296
|
+
"[bold green]All conflicts resolved. Memory is clean.[/bold green]",
|
|
297
|
+
title="[bold green]NO CONFLICTS[/bold green]", border_style="green", expand=False
|
|
298
|
+
))
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
for i, d in enumerate(flagged):
|
|
302
|
+
console.print()
|
|
303
|
+
console.print(f"[bold cyan]Conflict {i+1} of {len(flagged)}[/bold cyan] in module: [bold white]{d['module']}[/bold white]")
|
|
304
|
+
|
|
305
|
+
commit_ref = d.get('last_seen_commit') or "unknown commit"
|
|
306
|
+
|
|
307
|
+
active = get_active_decisions_by_module(db_str, d['module'])
|
|
308
|
+
old_id = None
|
|
309
|
+
if active:
|
|
310
|
+
old = active[0]
|
|
311
|
+
old_id = old['id']
|
|
312
|
+
conf_color = "bold bright_green" if old['confidence'] == 'HIGH' else ("bold bright_yellow" if old['confidence'] == 'MEDIUM' else "dim white")
|
|
313
|
+
old_panel = Panel(
|
|
314
|
+
f"{old['decision']}\n\n[dim]([/dim][{conf_color}]{old['confidence']}[/{conf_color}][dim] | reinforced x{old['reinforcement_count']})[/dim]",
|
|
315
|
+
title="[bold yellow]Existing Memory[/bold yellow]",
|
|
316
|
+
border_style="yellow",
|
|
317
|
+
expand=True
|
|
318
|
+
)
|
|
319
|
+
else:
|
|
320
|
+
old_panel = Panel(
|
|
321
|
+
"\n[dim]No existing memory found.[/dim]\n",
|
|
322
|
+
title="[bold yellow]Existing Memory[/bold yellow]",
|
|
323
|
+
border_style="yellow",
|
|
324
|
+
expand=True
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
new_conf_color = "bold bright_green" if d['confidence'] == 'HIGH' else ("bold bright_yellow" if d['confidence'] == 'MEDIUM' else "dim white")
|
|
328
|
+
new_panel = Panel(
|
|
329
|
+
f"{d['decision']}\n\n[dim]([/dim][{new_conf_color}]{d['confidence']}[/{new_conf_color}][dim] | commit {commit_ref[:7]})[/dim]",
|
|
330
|
+
title="[bold red]New Conflicting Extraction[/bold red]",
|
|
331
|
+
border_style="red",
|
|
332
|
+
expand=True
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
console.print(Columns([old_panel, new_panel]))
|
|
336
|
+
|
|
337
|
+
console.print()
|
|
338
|
+
console.print(" [bold green][ a ][/bold green] Accept new (archive old) [bold red][ r ][/bold red] Reject new (keep old)")
|
|
339
|
+
console.print(" [bold yellow][ e ][/bold yellow] Edit manually [bold blue][ s ][/bold blue] Skip for now")
|
|
340
|
+
console.print()
|
|
341
|
+
|
|
342
|
+
while True:
|
|
343
|
+
choice = Prompt.ask("[bold cyan]Select action[/bold cyan]", choices=["a", "r", "e", "s"], show_choices=False).lower()
|
|
344
|
+
if choice == 's':
|
|
345
|
+
console.print("[dim]Skipped.[/dim]")
|
|
346
|
+
break
|
|
347
|
+
elif choice == 'a':
|
|
348
|
+
resolve_decision(db_str, d['id'], 'accept', old_id=old_id)
|
|
349
|
+
console.print(Panel(
|
|
350
|
+
"[bold green]Accepted new decision. Memory updated.[/bold green]",
|
|
351
|
+
border_style="green", expand=False
|
|
352
|
+
))
|
|
353
|
+
break
|
|
354
|
+
elif choice == 'r':
|
|
355
|
+
resolve_decision(db_str, d['id'], 'reject')
|
|
356
|
+
console.print(Panel(
|
|
357
|
+
"[bold red]Rejected new decision. Existing memory preserved.[/bold red]",
|
|
358
|
+
border_style="red", expand=False
|
|
359
|
+
))
|
|
360
|
+
break
|
|
361
|
+
elif choice == 'e':
|
|
362
|
+
new_text = click.edit(d['decision'])
|
|
363
|
+
if new_text is not None:
|
|
364
|
+
new_text = new_text.strip()
|
|
365
|
+
resolve_decision(db_str, d['id'], 'edit', old_id=old_id, new_text=new_text)
|
|
366
|
+
console.print(Panel(
|
|
367
|
+
"[bold yellow]Saved edited decision. Memory updated.[/bold yellow]",
|
|
368
|
+
border_style="yellow", expand=False
|
|
369
|
+
))
|
|
370
|
+
break
|
|
371
|
+
else:
|
|
372
|
+
console.print("[red]Edit cancelled. Please choose an option.[/red]")
|
|
373
|
+
|
|
374
|
+
@cli.command()
|
|
375
|
+
def status():
|
|
376
|
+
"""Show the current memory store status."""
|
|
377
|
+
klyd_dir = Path('.klyd')
|
|
378
|
+
db_path = klyd_dir / 'memory.db'
|
|
379
|
+
if not db_path.exists():
|
|
380
|
+
console.print("[bold red]klyd is not initialized. Run `kl init`.[/bold red]")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
conn = sqlite3.connect(str(db_path))
|
|
384
|
+
conn.row_factory = sqlite3.Row
|
|
385
|
+
cur = conn.cursor()
|
|
386
|
+
|
|
387
|
+
cur.execute("SELECT * FROM decisions WHERE archived = 0")
|
|
388
|
+
all_decisions = [dict(r) for r in cur.fetchall()]
|
|
389
|
+
conn.close()
|
|
390
|
+
|
|
391
|
+
active = [d for d in all_decisions if d['flagged'] == 0]
|
|
392
|
+
flagged = [d for d in all_decisions if d['flagged'] == 1]
|
|
393
|
+
|
|
394
|
+
active.sort(key=lambda x: x['reinforcement_count'], reverse=True)
|
|
395
|
+
|
|
396
|
+
table = Table(box=rich.box.SIMPLE, header_style="bold cyan", show_edge=False, padding=(0, 2))
|
|
397
|
+
table.add_column("ID", style="dim", max_width=8)
|
|
398
|
+
table.add_column("Decision", style="white")
|
|
399
|
+
table.add_column("Module", style="bold blue")
|
|
400
|
+
table.add_column("Confidence")
|
|
401
|
+
table.add_column("Event")
|
|
402
|
+
table.add_column("Reinforcements", justify="right")
|
|
403
|
+
|
|
404
|
+
for d in active:
|
|
405
|
+
conf_color = "bold bright_green" if d['confidence'] == 'HIGH' else ("bold bright_yellow" if d['confidence'] == 'MEDIUM' else "dim white")
|
|
406
|
+
conf_styled = f"[{conf_color}]{d['confidence']}[/{conf_color}]"
|
|
407
|
+
|
|
408
|
+
event_color = "bright_blue" if d.get('event_type') == 'NEW' else ("bright_magenta" if d.get('event_type') == 'REINFORCE' else "bold bright_red")
|
|
409
|
+
event_styled = f"[{event_color}]{d.get('event_type', 'NEW')}[/{event_color}]"
|
|
410
|
+
|
|
411
|
+
table.add_row(
|
|
412
|
+
str(d['id'])[:8],
|
|
413
|
+
d['decision'],
|
|
414
|
+
d['module'],
|
|
415
|
+
conf_styled,
|
|
416
|
+
event_styled,
|
|
417
|
+
f"x{d['reinforcement_count']}"
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
if not active:
|
|
421
|
+
table.add_row("", "[dim]No active architectural decisions stored yet.[/dim]", "", "", "", "")
|
|
422
|
+
|
|
423
|
+
console.print(Panel(table, title="[bold cyan] Architectural Memory [/bold cyan]", border_style="cyan", expand=False, padding=(1, 2)))
|
|
424
|
+
|
|
425
|
+
if flagged:
|
|
426
|
+
console.print()
|
|
427
|
+
flagged_table = Table(box=rich.box.SIMPLE, show_edge=False, padding=(0, 2))
|
|
428
|
+
flagged_table.add_column("ID", style="dim", max_width=8)
|
|
429
|
+
flagged_table.add_column("Decision", style="bold white")
|
|
430
|
+
flagged_table.add_column("Module", style="bold blue")
|
|
431
|
+
flagged_table.add_column("Confidence")
|
|
432
|
+
|
|
433
|
+
for d in flagged:
|
|
434
|
+
conf_color = "bold bright_green" if d['confidence'] == 'HIGH' else ("bold bright_yellow" if d['confidence'] == 'MEDIUM' else "dim white")
|
|
435
|
+
conf_styled = f"[{conf_color}]{d['confidence']}[/{conf_color}]"
|
|
436
|
+
|
|
437
|
+
flagged_table.add_row(
|
|
438
|
+
str(d['id'])[:8],
|
|
439
|
+
d['decision'],
|
|
440
|
+
d['module'],
|
|
441
|
+
conf_styled
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
warning_panel = Panel(
|
|
445
|
+
flagged_table,
|
|
446
|
+
title="[bold white on red] NEEDS REVIEW - CONFLICTS DETECTED [/bold white on red]",
|
|
447
|
+
border_style="red",
|
|
448
|
+
expand=False,
|
|
449
|
+
padding=(1, 2)
|
|
450
|
+
)
|
|
451
|
+
console.print(warning_panel)
|
|
452
|
+
|
|
453
|
+
console.print()
|
|
454
|
+
console.print(f"[dim]Summary:[/dim] [cyan]{len(active)} active[/cyan] | [red]{len(flagged)} pending review[/red]")
|
klyd/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
def get_config_path():
|
|
5
|
+
return Path('.klyd') / 'config.json'
|
|
6
|
+
|
|
7
|
+
def init_config():
|
|
8
|
+
klyd_dir = Path('.klyd')
|
|
9
|
+
klyd_dir.mkdir(exist_ok=True)
|
|
10
|
+
|
|
11
|
+
config_path = get_config_path()
|
|
12
|
+
if not config_path.exists():
|
|
13
|
+
with open(config_path, 'w') as f:
|
|
14
|
+
json.dump({}, f, indent=2)
|
|
15
|
+
|
|
16
|
+
def set_config(key, value):
|
|
17
|
+
init_config()
|
|
18
|
+
config_path = get_config_path()
|
|
19
|
+
with open(config_path, 'r') as f:
|
|
20
|
+
data = json.load(f)
|
|
21
|
+
data[key] = value
|
|
22
|
+
with open(config_path, 'w') as f:
|
|
23
|
+
json.dump(data, f, indent=2)
|
|
24
|
+
|
|
25
|
+
def get_config(key, default=None):
|
|
26
|
+
config_path = get_config_path()
|
|
27
|
+
if not config_path.exists():
|
|
28
|
+
return default
|
|
29
|
+
try:
|
|
30
|
+
with open(config_path, 'r') as f:
|
|
31
|
+
data = json.load(f)
|
|
32
|
+
return data.get(key, default)
|
|
33
|
+
except:
|
|
34
|
+
return default
|
|
35
|
+
|
|
36
|
+
def get_all_config():
|
|
37
|
+
config_path = get_config_path()
|
|
38
|
+
if not config_path.exists():
|
|
39
|
+
return {}
|
|
40
|
+
try:
|
|
41
|
+
with open(config_path, 'r') as f:
|
|
42
|
+
return json.load(f)
|
|
43
|
+
except:
|
|
44
|
+
return {}
|
klyd/db.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import fnmatch
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
def get_schema_path():
|
|
6
|
+
return Path(__file__).resolve().parent.parent / 'schema' / 'v1.sql'
|
|
7
|
+
|
|
8
|
+
def init_db(db_path):
|
|
9
|
+
conn = sqlite3.connect(db_path)
|
|
10
|
+
schema = get_schema_path().read_text()
|
|
11
|
+
conn.executescript(schema)
|
|
12
|
+
conn.commit()
|
|
13
|
+
conn.close()
|
|
14
|
+
|
|
15
|
+
def store_decision(db_path, decision_dict):
|
|
16
|
+
conn = sqlite3.connect(db_path)
|
|
17
|
+
cur = conn.cursor()
|
|
18
|
+
cur.execute('''
|
|
19
|
+
INSERT INTO decisions (decision, module, file_patterns, confidence, event_type)
|
|
20
|
+
VALUES (?, ?, ?, ?, ?)
|
|
21
|
+
''', (
|
|
22
|
+
decision_dict['decision'],
|
|
23
|
+
decision_dict['module'],
|
|
24
|
+
decision_dict['file_patterns'],
|
|
25
|
+
decision_dict['confidence'],
|
|
26
|
+
decision_dict.get('event_type', 'NEW')
|
|
27
|
+
))
|
|
28
|
+
conn.commit()
|
|
29
|
+
decision_id = cur.lastrowid
|
|
30
|
+
conn.close()
|
|
31
|
+
return decision_id
|
|
32
|
+
|
|
33
|
+
def _match_any_file(file_patterns, file_list_str):
|
|
34
|
+
patterns = [p.strip() for p in file_patterns.split(',')]
|
|
35
|
+
files = file_list_str.split('|')
|
|
36
|
+
for f in files:
|
|
37
|
+
for p in patterns:
|
|
38
|
+
if fnmatch.fnmatch(f, p):
|
|
39
|
+
return 1
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
def get_decisions_for_files(db_path, file_list, top_k=5):
|
|
43
|
+
conn = sqlite3.connect(db_path)
|
|
44
|
+
conn.row_factory = sqlite3.Row
|
|
45
|
+
conn.create_function("match_files", 2, _match_any_file)
|
|
46
|
+
|
|
47
|
+
file_list_str = '|'.join(file_list)
|
|
48
|
+
|
|
49
|
+
cur = conn.cursor()
|
|
50
|
+
cur.execute('''
|
|
51
|
+
SELECT * FROM decisions
|
|
52
|
+
WHERE flagged = 0 AND archived = 0
|
|
53
|
+
AND match_files(file_patterns, ?) = 1
|
|
54
|
+
ORDER BY (reinforcement_count * CASE confidence WHEN 'HIGH' THEN 3 WHEN 'MEDIUM' THEN 2 ELSE 1 END) DESC, last_seen_commit DESC
|
|
55
|
+
LIMIT ?
|
|
56
|
+
''', (file_list_str, top_k))
|
|
57
|
+
|
|
58
|
+
results = [dict(row) for row in cur.fetchall()]
|
|
59
|
+
conn.close()
|
|
60
|
+
return results
|
|
61
|
+
|
|
62
|
+
def reinforce_decision(db_path, decision_id, commit_hash):
|
|
63
|
+
conn = sqlite3.connect(db_path)
|
|
64
|
+
conn.execute('''
|
|
65
|
+
UPDATE decisions
|
|
66
|
+
SET reinforcement_count = reinforcement_count + 1, last_seen_commit = ?
|
|
67
|
+
WHERE id = ?
|
|
68
|
+
''', (commit_hash, decision_id))
|
|
69
|
+
conn.commit()
|
|
70
|
+
conn.close()
|
|
71
|
+
|
|
72
|
+
def flag_decision(db_path, decision_id):
|
|
73
|
+
conn = sqlite3.connect(db_path)
|
|
74
|
+
conn.execute('UPDATE decisions SET flagged = 1 WHERE id = ?', (decision_id,))
|
|
75
|
+
conn.commit()
|
|
76
|
+
conn.close()
|
|
77
|
+
|
|
78
|
+
def archive_decision(db_path, decision_id):
|
|
79
|
+
conn = sqlite3.connect(db_path)
|
|
80
|
+
conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (decision_id,))
|
|
81
|
+
conn.commit()
|
|
82
|
+
conn.close()
|
|
83
|
+
|
|
84
|
+
def get_flagged_decisions(db_path):
|
|
85
|
+
conn = sqlite3.connect(db_path)
|
|
86
|
+
conn.row_factory = sqlite3.Row
|
|
87
|
+
cur = conn.cursor()
|
|
88
|
+
cur.execute("SELECT * FROM decisions WHERE flagged = 1 AND archived = 0")
|
|
89
|
+
res = [dict(r) for r in cur.fetchall()]
|
|
90
|
+
conn.close()
|
|
91
|
+
return res
|
|
92
|
+
|
|
93
|
+
def get_active_decisions_by_module(db_path, module):
|
|
94
|
+
conn = sqlite3.connect(db_path)
|
|
95
|
+
conn.row_factory = sqlite3.Row
|
|
96
|
+
cur = conn.cursor()
|
|
97
|
+
cur.execute("SELECT * FROM decisions WHERE module = ? AND flagged = 0 AND archived = 0 ORDER BY reinforcement_count DESC", (module,))
|
|
98
|
+
res = [dict(r) for r in cur.fetchall()]
|
|
99
|
+
conn.close()
|
|
100
|
+
return res
|
|
101
|
+
|
|
102
|
+
def resolve_decision(db_path, decision_id, action, old_id=None, new_text=None):
|
|
103
|
+
conn = sqlite3.connect(db_path)
|
|
104
|
+
if action == 'accept':
|
|
105
|
+
if old_id:
|
|
106
|
+
conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (old_id,))
|
|
107
|
+
conn.execute("UPDATE decisions SET flagged = 0, confidence = 'MEDIUM' WHERE id = ?", (decision_id,))
|
|
108
|
+
elif action == 'reject':
|
|
109
|
+
conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (decision_id,))
|
|
110
|
+
elif action == 'edit':
|
|
111
|
+
if old_id:
|
|
112
|
+
conn.execute('UPDATE decisions SET archived = 1 WHERE id = ?', (old_id,))
|
|
113
|
+
conn.execute("UPDATE decisions SET flagged = 0, confidence = 'MEDIUM', decision = ? WHERE id = ?", (new_text, decision_id))
|
|
114
|
+
conn.commit()
|
|
115
|
+
conn.close()
|
|
116
|
+
|
|
117
|
+
def get_existing_decisions_for_files(file_paths: list[str]) -> list[dict]:
|
|
118
|
+
db_path = str(Path('.klyd/memory.db').resolve())
|
|
119
|
+
if not Path(db_path).exists():
|
|
120
|
+
return []
|
|
121
|
+
decisions = get_decisions_for_files(db_path, file_paths, top_k=50)
|
|
122
|
+
high_conf = [d for d in decisions if d['confidence'] == 'HIGH']
|
|
123
|
+
return [{'id': d['id'], 'decision': d['decision']} for d in high_conf[:5]]
|
|
124
|
+
|
klyd/extractor.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.request
|
|
3
|
+
from anthropic import Anthropic
|
|
4
|
+
from .db import get_existing_decisions_for_files
|
|
5
|
+
|
|
6
|
+
def _call_openai_compatible(url, key, model, prompt):
|
|
7
|
+
data = json.dumps({
|
|
8
|
+
"model": model,
|
|
9
|
+
"messages": [{"role": "user", "content": prompt}]
|
|
10
|
+
}).encode('utf-8')
|
|
11
|
+
headers = {
|
|
12
|
+
"Authorization": f"Bearer {key}",
|
|
13
|
+
"Content-Type": "application/json"
|
|
14
|
+
}
|
|
15
|
+
if "openrouter.ai" in url:
|
|
16
|
+
headers["HTTP-Referer"] = "https://github.com/getKlyd/klyd"
|
|
17
|
+
headers["X-Title"] = "klyd"
|
|
18
|
+
|
|
19
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
20
|
+
with urllib.request.urlopen(req) as response:
|
|
21
|
+
res = json.loads(response.read().decode('utf-8'))
|
|
22
|
+
return res['choices'][0]['message']['content']
|
|
23
|
+
|
|
24
|
+
def extract_decisions(diff, commit_message, existing_decisions, config_data, model='claude-sonnet-4-6'):
|
|
25
|
+
files = []
|
|
26
|
+
for line in diff.split('\n'):
|
|
27
|
+
if line.startswith('diff --git a/'):
|
|
28
|
+
parts = line.split(' b/')
|
|
29
|
+
if len(parts) == 2:
|
|
30
|
+
files.append(parts[1].strip())
|
|
31
|
+
|
|
32
|
+
invariants = get_existing_decisions_for_files(list(set(files)))
|
|
33
|
+
invariants_text = "\n".join([f"- [{inv['id']}] {inv['decision']}" for inv in invariants]) if invariants else "None"
|
|
34
|
+
|
|
35
|
+
prompt = f"""You are an architectural decision extractor for a software project.
|
|
36
|
+
|
|
37
|
+
You will receive a git diff, a commit message, and a list of previously recorded architectural decisions for the files touched in this diff.
|
|
38
|
+
|
|
39
|
+
Your job: extract the literal text of the architectural decisions that this commit clearly enacts.
|
|
40
|
+
|
|
41
|
+
Architectural decisions are: data store choice, auth strategy, API boundary contracts, module responsibility assignments, dependency/library choices, error handling patterns.
|
|
42
|
+
|
|
43
|
+
Rules:
|
|
44
|
+
- Only record if the diff explicitly introduces, changes, or contradicts a decision
|
|
45
|
+
- Do not guess. Do not infer. Only record what is clearly shown in the diff.
|
|
46
|
+
- If the commit is a style fix, test update, or minor refactor with no architectural significance: return []
|
|
47
|
+
- For each decision, classify as NEW (not seen before), REINFORCE (confirms an existing decision), or CONTRADICT (conflicts with an existing decision)
|
|
48
|
+
- Assign confidence: HIGH (unmistakable from diff), MEDIUM (clear but could have context), LOW (possible but uncertain)
|
|
49
|
+
- If this commit contradicts any of the listed invariants, set event_type to 'CONTRADICT' and add a note in the 'decision' field describing the violation. Otherwise, proceed with NEW or REINFORCE as before.
|
|
50
|
+
|
|
51
|
+
Return ONLY valid JSON. No prose. No markdown. No explanation.
|
|
52
|
+
- The 'decision' field must contain the exact architectural rule or invariant written in the diff, quoted verbatim if possible. Do not summarise module responsibilities. For example, prefer 'Authentication uses JWT access tokens exclusively; no server-side sessions' over 'Authentication is handled by auth.py'.
|
|
53
|
+
Schema: [{{"decision": str, "module": str, "file_patterns": str, "confidence": "LOW"|"MEDIUM"|"HIGH", "event": "NEW"|"REINFORCE"|"CONTRADICT"}}]
|
|
54
|
+
If no decisions: return []
|
|
55
|
+
|
|
56
|
+
EXISTING DECISIONS FOR TOUCHED FILES:
|
|
57
|
+
{existing_decisions}
|
|
58
|
+
|
|
59
|
+
COMMIT MESSAGE:
|
|
60
|
+
{commit_message}
|
|
61
|
+
|
|
62
|
+
DIFF:
|
|
63
|
+
{diff}
|
|
64
|
+
|
|
65
|
+
Existing architectural invariants (do not violate unless explicitly instructed):
|
|
66
|
+
{invariants_text}
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
# Determine provider
|
|
70
|
+
if model.startswith('claude-') or 'api_key' in config_data and not any(k in config_data for k in ['openai_key', 'openrouter_key', 'gemini_key', 'groq_key']):
|
|
71
|
+
if 'api_key' not in config_data:
|
|
72
|
+
raise ValueError("Anthropic API key is required.")
|
|
73
|
+
client = Anthropic(api_key=config_data['api_key'])
|
|
74
|
+
response = client.messages.create(
|
|
75
|
+
model=model,
|
|
76
|
+
max_tokens=1024,
|
|
77
|
+
messages=[{"role": "user", "content": prompt}]
|
|
78
|
+
)
|
|
79
|
+
content = response.content[0].text.strip()
|
|
80
|
+
else:
|
|
81
|
+
# OpenAI compatible providers
|
|
82
|
+
if model.startswith('gpt-') or model.startswith('o1') or model.startswith('o3'):
|
|
83
|
+
if 'openai_key' not in config_data: raise ValueError("OpenAI API key missing.")
|
|
84
|
+
url, key = "https://api.openai.com/v1/chat/completions", config_data['openai_key']
|
|
85
|
+
elif model.startswith('gemini-') or model.startswith('gemma-'):
|
|
86
|
+
if 'gemini_key' not in config_data: raise ValueError("Gemini API key missing.")
|
|
87
|
+
url, key = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", config_data['gemini_key']
|
|
88
|
+
elif '/' in model:
|
|
89
|
+
if 'openrouter_key' not in config_data: raise ValueError("OpenRouter API key missing.")
|
|
90
|
+
url, key = "https://openrouter.ai/api/v1/chat/completions", config_data['openrouter_key']
|
|
91
|
+
elif config_data.get('groq_key'):
|
|
92
|
+
url, key = "https://api.groq.com/openai/v1/chat/completions", config_data['groq_key']
|
|
93
|
+
elif config_data.get('openai_key'):
|
|
94
|
+
url, key = "https://api.openai.com/v1/chat/completions", config_data['openai_key']
|
|
95
|
+
elif config_data.get('openrouter_key'):
|
|
96
|
+
url, key = "https://openrouter.ai/api/v1/chat/completions", config_data['openrouter_key']
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f"No valid API key configured for model: {model}")
|
|
99
|
+
|
|
100
|
+
content = _call_openai_compatible(url, key, model, prompt)
|
|
101
|
+
|
|
102
|
+
# Parse output as JSON
|
|
103
|
+
content = content.strip()
|
|
104
|
+
if content.startswith('```json'):
|
|
105
|
+
content = content[7:]
|
|
106
|
+
if content.endswith('```'):
|
|
107
|
+
content = content[:-3]
|
|
108
|
+
|
|
109
|
+
content = content.strip()
|
|
110
|
+
|
|
111
|
+
result = json.loads(content)
|
|
112
|
+
if not isinstance(result, list):
|
|
113
|
+
return []
|
|
114
|
+
|
|
115
|
+
normalized = []
|
|
116
|
+
for r in result:
|
|
117
|
+
event_val = r.get('event') or r.get('event_type') or 'NEW'
|
|
118
|
+
normalized.append({
|
|
119
|
+
'decision': r.get('decision', 'Unknown'),
|
|
120
|
+
'module': r.get('module', '/'),
|
|
121
|
+
'file_patterns': r.get('file_patterns', '*'),
|
|
122
|
+
'confidence': r.get('confidence', 'LOW'),
|
|
123
|
+
'event_type': event_val
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return normalized
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
raise
|
klyd/hooks.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import stat
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
def get_template_path(hook_name):
|
|
7
|
+
# Try to find the hooks directory relative to this file
|
|
8
|
+
base_dir = Path(__file__).resolve().parent.parent
|
|
9
|
+
hook_path = base_dir / 'hooks' / f"{hook_name}.sh"
|
|
10
|
+
return hook_path
|
|
11
|
+
|
|
12
|
+
def install_hooks():
|
|
13
|
+
git_dir = Path('.git')
|
|
14
|
+
if not git_dir.is_dir():
|
|
15
|
+
raise RuntimeError("Not a git repository. Please run in the root of a git repo.")
|
|
16
|
+
|
|
17
|
+
git_hooks_dir = git_dir / 'hooks'
|
|
18
|
+
git_hooks_dir.mkdir(exist_ok=True)
|
|
19
|
+
|
|
20
|
+
for hook in ['post-commit', 'pre-commit']:
|
|
21
|
+
src = get_template_path(hook)
|
|
22
|
+
if not src.exists():
|
|
23
|
+
raise RuntimeError(f"Hook template not found: {src}")
|
|
24
|
+
|
|
25
|
+
dest = git_hooks_dir / hook
|
|
26
|
+
shutil.copy2(src, dest)
|
|
27
|
+
|
|
28
|
+
# chmod +x
|
|
29
|
+
dest.chmod(dest.stat().st_mode | stat.S_IEXEC)
|
|
30
|
+
|
|
31
|
+
def uninstall_hooks():
|
|
32
|
+
git_dir = Path('.git')
|
|
33
|
+
if not git_dir.is_dir():
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
git_hooks_dir = git_dir / 'hooks'
|
|
37
|
+
for hook in ['post-commit', 'pre-commit']:
|
|
38
|
+
dest = git_hooks_dir / hook
|
|
39
|
+
if dest.exists() and "klyd" in dest.read_text():
|
|
40
|
+
dest.unlink()
|
klyd/injector.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
def format_injection(decisions):
|
|
2
|
+
if not decisions:
|
|
3
|
+
return ""
|
|
4
|
+
|
|
5
|
+
lines = [
|
|
6
|
+
"[klyd] Architectural decisions governing files in this session:\n"
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
for i, d in enumerate(decisions, 1):
|
|
10
|
+
mod = f"[{d['module']}]"
|
|
11
|
+
conf = f"{d['confidence']} confidence"
|
|
12
|
+
count = f"confirmed {d['reinforcement_count']} times"
|
|
13
|
+
dec = f"{d['decision']}."
|
|
14
|
+
lines.append(f"{i}. {mod} {dec} ({conf}, {count})")
|
|
15
|
+
|
|
16
|
+
lines.append("\nDo not contradict these decisions unless the user explicitly instructs you to change them.")
|
|
17
|
+
|
|
18
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: klyd
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A CLI tool that wraps coding agents via git hooks to inject architectural memory.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: anthropic
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
<center>
|
|
13
|
+
|
|
14
|
+
```ascii
|
|
15
|
+
|
|
16
|
+
▄█ ▄█▄ ▄█ ▄██ ▄ ████████▄
|
|
17
|
+
███ ▄███▀ ███ ███ ██▄ ███ ▀███
|
|
18
|
+
███▐██▀ ███ ███▄▄▄███ ███ ███
|
|
19
|
+
▄█████▀ ███ ▀▀▀▀▀▀███ ███ ███
|
|
20
|
+
▀▀█████▄ ███ ▄██ ███ ███ ███
|
|
21
|
+
███▐██▄ ███ ███ ███ ███ ███
|
|
22
|
+
███ ▀███▄ ███▌ ▄ ███ ███ ███ ▄███
|
|
23
|
+
███ ▀█▀ █████▄▄██ ▀█████▀ ████████▀
|
|
24
|
+
▀ ▀
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
</center>
|
|
28
|
+
|
|
29
|
+
install → klyd init → klyd config --api-key sk-... → klyd run aider
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 1. Install
|
|
35
|
+
pip install .
|
|
36
|
+
|
|
37
|
+
# 2. Initialize in your repo
|
|
38
|
+
cd your-project
|
|
39
|
+
klyd init
|
|
40
|
+
|
|
41
|
+
# 3. Configure an API Key
|
|
42
|
+
# Anthropic (Default)
|
|
43
|
+
klyd config --api-key sk-ant-...
|
|
44
|
+
|
|
45
|
+
# OR OpenAI
|
|
46
|
+
klyd config --openai-key sk-proj-... --model gpt-4o
|
|
47
|
+
|
|
48
|
+
# OR OpenRouter
|
|
49
|
+
klyd config --openrouter-key sk-or-... --model openrouter/auto
|
|
50
|
+
|
|
51
|
+
# OR Gemini
|
|
52
|
+
klyd config --gemini-key AIza... --model gemini-1.5-pro
|
|
53
|
+
|
|
54
|
+
# OR Groq
|
|
55
|
+
klyd config --groq-key gsk_... --model llama3-8b-8192
|
|
56
|
+
|
|
57
|
+
# 4. Use your agent as normal
|
|
58
|
+
klyd run aider
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Decisions will automatically populate after you make commits. Review flagged contradictions using `klyd review`. Check current status with `klyd status`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
klyd/__init__.py,sha256=z5PJeZXzDyLuY_pP0XOFQE6V31c70-VAgSE-1z-SNBI,298
|
|
2
|
+
klyd/cli.py,sha256=JnYUNbzl7fKBxWdJPvC1teNdtugCVwFeVHDK59H9UWg,20955
|
|
3
|
+
klyd/config.py,sha256=ZXySISxRV3D7jsB5qSBuu-C82RUgJ_Yg6nqyROLS8AU,1137
|
|
4
|
+
klyd/db.py,sha256=DFHbiXVAYtv4FRTBqsSB2Wv2b12nxENw8X_ogm39dLw,4478
|
|
5
|
+
klyd/extractor.py,sha256=M80GcjkKrsEMR4G9sIuDfmHTNcFOH9rVoMNRqz4rfW0,6490
|
|
6
|
+
klyd/hooks.py,sha256=1cstO379x37pidLQ7jkzsABhjmcAuxe-fD-RU2EzJ10,1221
|
|
7
|
+
klyd/injector.py,sha256=A6naOu3lNt5_TiB2qx8nRKB3p99S6Z23_XZiyiCuJRA,625
|
|
8
|
+
klyd-0.1.0.dist-info/licenses/LICENSE,sha256=sXUkxFuaD0caO8QY4N6c4kCTEnL025WX2hcRX1KpDrE,1095
|
|
9
|
+
klyd-0.1.0.dist-info/METADATA,sha256=DaPD3CZ2Uc-9kXo6E_At-uU8oWhN1BT4j9BtL9AN_pA,2362
|
|
10
|
+
klyd-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
klyd-0.1.0.dist-info/entry_points.txt,sha256=pea2qEJw4ckfTwVqu-n2Tv_T2AN1yMInKfDSWRvJu0U,36
|
|
12
|
+
klyd-0.1.0.dist-info/top_level.txt,sha256=dSrJdWpiMdicMB0VYtWqwWL93PEfr2mOBlRlz7Um10s,5
|
|
13
|
+
klyd-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 klyd contributors
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
klyd
|