lcode-cli 0.6.0__py3-none-any.whl → 0.7.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.
- lcode/__init__.py +1 -1
- lcode/agent.py +26 -4
- lcode/cli.py +10 -0
- lcode/config.py +8 -0
- lcode/memory.py +517 -0
- lcode/permissions.py +8 -3
- lcode/repl.py +137 -0
- lcode/tools.py +43 -1
- {lcode_cli-0.6.0.dist-info → lcode_cli-0.7.0.dist-info}/METADATA +8 -1
- {lcode_cli-0.6.0.dist-info → lcode_cli-0.7.0.dist-info}/RECORD +13 -12
- {lcode_cli-0.6.0.dist-info → lcode_cli-0.7.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.6.0.dist-info → lcode_cli-0.7.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.6.0.dist-info → lcode_cli-0.7.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -21,6 +21,7 @@ from rich.panel import Panel
|
|
|
21
21
|
from rich.text import Text
|
|
22
22
|
|
|
23
23
|
from lcode import catalog, limits, sessions, vision, web
|
|
24
|
+
from lcode import memory as memory_notes
|
|
24
25
|
from lcode.checkpoints import Checkpoints
|
|
25
26
|
from lcode.config import format_tokens
|
|
26
27
|
from lcode.mcp import McpManager
|
|
@@ -29,6 +30,7 @@ from lcode.permissions import Permissions
|
|
|
29
30
|
from lcode.render import MarkdownStreamer
|
|
30
31
|
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
31
32
|
from lcode.tools import (
|
|
33
|
+
MEMORY_SCHEMA,
|
|
32
34
|
SCHEMAS,
|
|
33
35
|
VIEW_IMAGE_SCHEMA,
|
|
34
36
|
WEB_FETCH_SCHEMA,
|
|
@@ -74,7 +76,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
|
|
|
74
76
|
|
|
75
77
|
# Top-level layout of the working directory
|
|
76
78
|
{tree}
|
|
77
|
-
{web}{
|
|
79
|
+
{web}{project}{notes}"""
|
|
78
80
|
|
|
79
81
|
WEB_PROMPT = """
|
|
80
82
|
# Web access
|
|
@@ -148,6 +150,7 @@ class Settings:
|
|
|
148
150
|
sandbox_image: str | None = None
|
|
149
151
|
sandbox_network: bool = False
|
|
150
152
|
vision_model: str = "auto" # auto, off or an Ollama model that can see images
|
|
153
|
+
memory: str = "off" # off, ask or auto: notes that carry over to later sessions (lcode.memory)
|
|
151
154
|
|
|
152
155
|
|
|
153
156
|
class Agent:
|
|
@@ -170,6 +173,9 @@ class Agent:
|
|
|
170
173
|
self._vision: str | bool | None = False # the model that looks at images; False = not decided yet
|
|
171
174
|
self.session_name = ""
|
|
172
175
|
self.session_title = ""
|
|
176
|
+
self.interactive = True # False for `lcode -p`: no end-of-session questions
|
|
177
|
+
self.reflected = 0 # messages before this index were already checked for notes to remember
|
|
178
|
+
self._memory: memory_notes.Memory | None = None
|
|
173
179
|
self.ctx_used = 0
|
|
174
180
|
self.last_speed = 0.0
|
|
175
181
|
self.usage = {"requests": 0, "prompt_tokens": 0, "prompt_ns": 0, "output_tokens": 0, "output_ns": 0}
|
|
@@ -182,11 +188,11 @@ class Agent:
|
|
|
182
188
|
return dt.datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
|
|
183
189
|
|
|
184
190
|
def system_prompt(self) -> str:
|
|
185
|
-
|
|
191
|
+
project = ""
|
|
186
192
|
for name in PROJECT_FILES:
|
|
187
193
|
f = self.cwd / name
|
|
188
194
|
if f.is_file():
|
|
189
|
-
|
|
195
|
+
project = f"\n# Project instructions ({name})\n{truncate(f.read_text(errors='replace'), 20_000)}\n"
|
|
190
196
|
break
|
|
191
197
|
return SYSTEM_PROMPT.format(
|
|
192
198
|
model=self.settings.model,
|
|
@@ -196,10 +202,17 @@ class Agent:
|
|
|
196
202
|
date=dt.date.today().isoformat(),
|
|
197
203
|
git=git_info(self.cwd),
|
|
198
204
|
tree=tree(self.cwd, depth=1, limit=120),
|
|
199
|
-
|
|
205
|
+
project=project,
|
|
206
|
+
notes=self.memory().prompt(self.settings.context) if self.settings.memory != "off" else "",
|
|
200
207
|
web=self.web_prompt(),
|
|
201
208
|
)
|
|
202
209
|
|
|
210
|
+
def memory(self) -> memory_notes.Memory:
|
|
211
|
+
"""The notes for the current folder's repository, plus the user's own."""
|
|
212
|
+
if self._memory is None or self._memory.cwd != self.cwd:
|
|
213
|
+
self._memory = memory_notes.Memory(self.cwd)
|
|
214
|
+
return self._memory
|
|
215
|
+
|
|
203
216
|
def search_backend(self) -> str | None:
|
|
204
217
|
if self.settings.web == "off":
|
|
205
218
|
return None
|
|
@@ -211,6 +224,8 @@ class Agent:
|
|
|
211
224
|
schemas += [*([WEB_SEARCH_SCHEMA] if self.search_backend() else []), WEB_FETCH_SCHEMA]
|
|
212
225
|
if self.vision_model():
|
|
213
226
|
schemas.append(VIEW_IMAGE_SCHEMA)
|
|
227
|
+
if self.settings.memory != "off":
|
|
228
|
+
schemas.append(MEMORY_SCHEMA)
|
|
214
229
|
if self.mcp:
|
|
215
230
|
schemas += self.mcp.schemas(self.settings.context)
|
|
216
231
|
return schemas
|
|
@@ -301,6 +316,7 @@ class Agent:
|
|
|
301
316
|
self.messages = [{"role": "system", "content": self.system_prompt()}]
|
|
302
317
|
self._mcp_prompt = ""
|
|
303
318
|
self.tools.read_mtimes.clear()
|
|
319
|
+
self.reflected = len(self.messages)
|
|
304
320
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
305
321
|
|
|
306
322
|
def session_file(self) -> Path:
|
|
@@ -357,6 +373,7 @@ class Agent:
|
|
|
357
373
|
self.session_name = data.get("name", "")
|
|
358
374
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
359
375
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
376
|
+
self.reflected = len(self.messages) # checked for notes to remember when that session ended
|
|
360
377
|
self.ctx_used = sum(len(json.dumps(m)) for m in self.messages) // 3
|
|
361
378
|
return note
|
|
362
379
|
|
|
@@ -513,6 +530,8 @@ class Agent:
|
|
|
513
530
|
return f"glob({args.get('pattern', '')})"
|
|
514
531
|
if name == "list_dir":
|
|
515
532
|
return f"list_dir({args.get('path', '.')})"
|
|
533
|
+
if name == "memory":
|
|
534
|
+
return f"memory({' '.join(str(args.get(k) or '') for k in ('action', 'name')).strip()})"
|
|
516
535
|
if self.mcp and self.mcp.owns(name):
|
|
517
536
|
if name == "mcp_find_tools":
|
|
518
537
|
return f"mcp_find_tools({args.get('query', '')!r})"
|
|
@@ -646,6 +665,8 @@ class Agent:
|
|
|
646
665
|
def compact(self, focus: str = "") -> None:
|
|
647
666
|
if len(self.messages) <= 2:
|
|
648
667
|
return
|
|
668
|
+
if self.interactive:
|
|
669
|
+
memory_notes.reflect(self) # what the summary leaves out is gone for good
|
|
649
670
|
instructions = (
|
|
650
671
|
"Summarize this conversation so the work can continue in a fresh context. Include: the user's goals "
|
|
651
672
|
"and requests, key facts learned about the codebase (files, functions, paths with line numbers), "
|
|
@@ -664,6 +685,7 @@ class Agent:
|
|
|
664
685
|
{"role": "assistant", "content": "Got it — I have the context from the summary and will continue."},
|
|
665
686
|
]
|
|
666
687
|
self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
|
|
688
|
+
self.reflected = len(self.messages)
|
|
667
689
|
self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
|
|
668
690
|
|
|
669
691
|
# -- settings changes
|
lcode/cli.py
CHANGED
|
@@ -341,6 +341,13 @@ def cmd_doctor(args) -> None:
|
|
|
341
341
|
network = "network on" if cfg["sandbox_network"] else "no network"
|
|
342
342
|
line("Sandbox", problem or f"{cfg['sandbox']} · {network}", problem is None)
|
|
343
343
|
ok &= problem is None
|
|
344
|
+
if cfg["memory"] == "off":
|
|
345
|
+
line("Memory", "off (lcode config set memory ask)", True)
|
|
346
|
+
else:
|
|
347
|
+
from lcode.memory import Memory
|
|
348
|
+
|
|
349
|
+
project, user = Memory(Path.cwd()).counts()
|
|
350
|
+
line("Memory", f"{cfg['memory']} · {project} note(s) for this repository, {user} for every repository", True)
|
|
344
351
|
node = shutil.which("npx")
|
|
345
352
|
line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
|
|
346
353
|
sys.exit(0 if ok else 1)
|
|
@@ -509,8 +516,10 @@ def cmd_chat(args) -> None:
|
|
|
509
516
|
sandbox_image=cfg["sandbox_image"],
|
|
510
517
|
sandbox_network=cfg["sandbox_network"],
|
|
511
518
|
vision_model=cfg["vision_model"],
|
|
519
|
+
memory="off" if args.no_memory else cfg["memory"],
|
|
512
520
|
)
|
|
513
521
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
522
|
+
agent.interactive = not args.prompt
|
|
514
523
|
if agent.sandbox:
|
|
515
524
|
# Never run commands unsandboxed when the user asked for a sandbox: stop here instead.
|
|
516
525
|
from lcode.sandbox import SandboxError
|
|
@@ -557,6 +566,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
557
566
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
|
558
567
|
parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
|
|
559
568
|
parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
|
|
569
|
+
parser.add_argument("--no-memory", action="store_true", help="don't load or save memory notes in this session")
|
|
560
570
|
parser.add_argument("--sandbox", action="store_true", help="run shell commands in a container (Docker or Podman)")
|
|
561
571
|
parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
|
|
562
572
|
parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
|
lcode/config.py
CHANGED
|
@@ -26,6 +26,7 @@ WEB_MODES = ("on", "ask", "off")
|
|
|
26
26
|
SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
|
|
27
27
|
MCP_TOOL_MODES = ("auto", "direct", "search")
|
|
28
28
|
SANDBOX_ENGINES = ("off", "docker", "podman")
|
|
29
|
+
MEMORY_MODES = ("off", "ask", "auto")
|
|
29
30
|
|
|
30
31
|
# key -> (default, type, help)
|
|
31
32
|
SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
@@ -45,6 +46,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
45
46
|
"sandbox_network": (False, bool, "let commands in the sandbox use the network"),
|
|
46
47
|
"vision_model": ("auto", str, "model that looks at images: auto | off | an Ollama model with vision"),
|
|
47
48
|
"mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
|
|
49
|
+
"memory": ("ask", str, "notes that carry over to later sessions: off | ask (confirm each) | auto"),
|
|
48
50
|
}
|
|
49
51
|
ENV_OVERRIDES = {
|
|
50
52
|
"LCODE_MODEL": "model",
|
|
@@ -54,6 +56,7 @@ ENV_OVERRIDES = {
|
|
|
54
56
|
"OLLAMA_HOST": "ollama_host",
|
|
55
57
|
"LCODE_WEB": "web",
|
|
56
58
|
"LCODE_SANDBOX": "sandbox",
|
|
59
|
+
"LCODE_MEMORY": "memory",
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
|
|
@@ -110,6 +113,11 @@ def coerce(key: str, value: object) -> object:
|
|
|
110
113
|
if value not in SANDBOX_ENGINES:
|
|
111
114
|
raise ConfigError(f"sandbox must be one of {', '.join(SANDBOX_ENGINES)}")
|
|
112
115
|
return value
|
|
116
|
+
if key == "memory":
|
|
117
|
+
value = {"on": "ask", "true": "ask", "false": "off", "no": "off"}.get(str(value).lower(), str(value).lower())
|
|
118
|
+
if value not in MEMORY_MODES:
|
|
119
|
+
raise ConfigError(f"memory must be one of {', '.join(MEMORY_MODES)}")
|
|
120
|
+
return value
|
|
113
121
|
if key == "mcp_tools" and value not in MCP_TOOL_MODES:
|
|
114
122
|
raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
|
|
115
123
|
if key == "search_backend" and value not in SEARCH_BACKENDS:
|
lcode/memory.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Memory across sessions: short notes about what a future session should know.
|
|
2
|
+
|
|
3
|
+
Each note is a markdown file holding one fact, with a small frontmatter:
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
name: test-command
|
|
7
|
+
type: project
|
|
8
|
+
description: Run the tests with `uv run pytest -q`; plain pytest misses the dev dependencies
|
|
9
|
+
modified: 2026-10-05
|
|
10
|
+
---
|
|
11
|
+
Optional details: why, and how to apply it.
|
|
12
|
+
|
|
13
|
+
Notes live in lcode's state folder: `memory/user/` holds the user's own preferences, for every
|
|
14
|
+
repository, and `memory/projects/<repo>/` holds one repository's notes, shared by all its git
|
|
15
|
+
worktrees. The system prompt gets a one-line index of both, capped so it costs little context;
|
|
16
|
+
the model reads a note's details with the memory tool.
|
|
17
|
+
|
|
18
|
+
Notes come from three places: the model's memory tool, the user's /remember, and a short
|
|
19
|
+
reflection when a session ends ("what from this session is worth remembering?"), because local
|
|
20
|
+
models rarely save anything on their own.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import difflib
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
import tempfile
|
|
33
|
+
from dataclasses import dataclass, replace
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import TYPE_CHECKING
|
|
36
|
+
|
|
37
|
+
from rich.markup import escape
|
|
38
|
+
from rich.panel import Panel
|
|
39
|
+
from rich.text import Text
|
|
40
|
+
|
|
41
|
+
from lcode import config
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from lcode.agent import Agent
|
|
45
|
+
|
|
46
|
+
TYPES = ("feedback", "project", "reference", "user")
|
|
47
|
+
SCOPES = ("project", "user")
|
|
48
|
+
MAX_DESCRIPTION = 200 # characters: a note's description is one line
|
|
49
|
+
MAX_DETAILS = 1000
|
|
50
|
+
MAX_INDEX_CHARS = 6000 # ~2K tokens of the system prompt at most
|
|
51
|
+
SIMILAR = 0.9 # descriptions this alike say the same thing
|
|
52
|
+
MAX_REFLECTION_NOTES = 3
|
|
53
|
+
# A session is worth a reflection when it had a few requests, or one request with real work in it.
|
|
54
|
+
REFLECT_MIN_REQUESTS = 2
|
|
55
|
+
REFLECT_MIN_TOOL_CALLS = 4
|
|
56
|
+
|
|
57
|
+
# Never saved: text that looks like a credential. The label tells the model what to leave out.
|
|
58
|
+
SECRET_PATTERNS = [
|
|
59
|
+
("a private key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
60
|
+
("an AWS access key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
|
|
61
|
+
("a GitHub token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})")),
|
|
62
|
+
("a GitLab token", re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}")),
|
|
63
|
+
("a Slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}")),
|
|
64
|
+
("an API key", re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_\-]{20,}")),
|
|
65
|
+
("a Google API key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
|
66
|
+
("a JSON Web Token", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}")),
|
|
67
|
+
("a password in a URL", re.compile(r"\b[a-z][a-z0-9+.\-]*://[^\s/:@]+:[^\s/@]+@", re.I)),
|
|
68
|
+
(
|
|
69
|
+
"a password or key",
|
|
70
|
+
re.compile(
|
|
71
|
+
r"\b(?:password|passwd|pwd|secret|token|api[_ -]?key|access[_ -]?key|client[_ -]?secret)\b"
|
|
72
|
+
r"\s*[:=]\s*['\"]?[^\s'\"]{6,}",
|
|
73
|
+
re.I,
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
PROMPT = """
|
|
79
|
+
# Memory
|
|
80
|
+
Notes saved in earlier sessions. They can be out of date: if one conflicts with what you find, trust what you find and update or delete the note.
|
|
81
|
+
{index}
|
|
82
|
+
- Save a note with the memory tool when you learn something a future session needs that isn't in the code, git history or AGENTS.md: a correction or preference from the user (feedback), a decision or constraint (project), where something lives outside the repository (reference), or who the user is (user). One fact per note; to change a note, save it again under the same name.
|
|
83
|
+
- Never save code structure, file paths, what you changed in this session, temporary state, or secrets.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
REFLECT_PROMPT = """[lcode] The session is ending. Is there anything from this conversation that a future session in this repository should know, and that isn't in the code, the git history or AGENTS.md? Worth saving:
|
|
87
|
+
- feedback: a correction or preference the user gave ("use pnpm, not npm", "don't add docstrings")
|
|
88
|
+
- project: a decision, constraint or ongoing work in this repository ("the staging database needs the VPN")
|
|
89
|
+
- reference: where something lives outside the repository (a dashboard, tracker or document)
|
|
90
|
+
- user: who the user is (role, expertise) and how they like to work in general
|
|
91
|
+
Not worth saving: code structure, file paths, what changed in this session, anything git records, temporary state, secrets. Most sessions have nothing worth saving: then answer with an empty list. At most {limit} notes. To update an existing note, reuse its name.
|
|
92
|
+
|
|
93
|
+
Existing notes:
|
|
94
|
+
{existing}
|
|
95
|
+
|
|
96
|
+
Scope: "project" for anything about this repository (almost always); "user" only for something about the user that holds in every repository.
|
|
97
|
+
|
|
98
|
+
Answer with JSON only: {{"notes": [{{"type": "feedback", "name": "short-kebab-case-name", "description": "the fact, in one line", "details": "optional: why, and how to apply it", "scope": "project"}}]}}"""
|
|
99
|
+
|
|
100
|
+
REFLECT_SCHEMA = {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"notes": {
|
|
104
|
+
"type": "array",
|
|
105
|
+
"items": {
|
|
106
|
+
"type": "object",
|
|
107
|
+
"properties": {
|
|
108
|
+
"type": {"type": "string", "enum": list(TYPES)},
|
|
109
|
+
"name": {"type": "string"},
|
|
110
|
+
"description": {"type": "string"},
|
|
111
|
+
"details": {"type": "string"},
|
|
112
|
+
"scope": {"type": "string", "enum": list(SCOPES)},
|
|
113
|
+
},
|
|
114
|
+
"required": ["type", "name", "description"],
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"required": ["notes"],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class NoteError(ValueError):
|
|
123
|
+
"""A note can't be saved; the message is meant for the model or the user."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class Note:
|
|
128
|
+
name: str
|
|
129
|
+
type: str
|
|
130
|
+
description: str
|
|
131
|
+
details: str = ""
|
|
132
|
+
modified: str = "" # YYYY-MM-DD
|
|
133
|
+
scope: str = "project"
|
|
134
|
+
path: Path | None = None
|
|
135
|
+
|
|
136
|
+
def line(self) -> str:
|
|
137
|
+
more = ", has details" if self.details else ""
|
|
138
|
+
return f"- [{self.type}] {self.description} ({self.name}, {self.modified}{more})"
|
|
139
|
+
|
|
140
|
+
def render(self) -> str:
|
|
141
|
+
head = (
|
|
142
|
+
f"---\nname: {self.name}\ntype: {self.type}\ndescription: {self.description}\n"
|
|
143
|
+
f"modified: {self.modified}\n---\n"
|
|
144
|
+
)
|
|
145
|
+
return head + (f"{self.details.strip()}\n" if self.details.strip() else "")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ----------------------------------------------------------------------------- notes on disk
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def memory_dir() -> Path:
|
|
152
|
+
return config.STATE_DIR / "memory"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def slugify(text: str, limit: int = 50) -> str:
|
|
156
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
157
|
+
if len(slug) > limit:
|
|
158
|
+
slug = slug[:limit].rsplit("-", 1)[0] or slug[:limit]
|
|
159
|
+
return slug
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _git_common_dir(cwd: Path) -> Path | None:
|
|
163
|
+
"""The repository's git directory, the same for every worktree of it (None outside git)."""
|
|
164
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
|
165
|
+
try:
|
|
166
|
+
r = subprocess.run(
|
|
167
|
+
["git", "rev-parse", "--git-common-dir"], cwd=cwd, capture_output=True, text=True, timeout=10, env=env
|
|
168
|
+
)
|
|
169
|
+
except (OSError, subprocess.SubprocessError):
|
|
170
|
+
return None
|
|
171
|
+
out = r.stdout.strip()
|
|
172
|
+
if r.returncode != 0 or not out:
|
|
173
|
+
return None
|
|
174
|
+
return (cwd / out).resolve()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def project_dir(cwd: Path) -> Path:
|
|
178
|
+
"""Where the notes for the repository around `cwd` live: shared by its worktrees."""
|
|
179
|
+
common = _git_common_dir(cwd)
|
|
180
|
+
if common:
|
|
181
|
+
root = common.parent if common.name == ".git" else common
|
|
182
|
+
name, ident = root.name.removesuffix(".git"), common
|
|
183
|
+
else:
|
|
184
|
+
name, ident = cwd.name, cwd.resolve()
|
|
185
|
+
key = hashlib.sha256(str(ident).encode()).hexdigest()[:8]
|
|
186
|
+
return memory_dir() / "projects" / f"{slugify(name, 40) or 'folder'}-{key}"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse(text: str, scope: str, path: Path | None = None) -> Note | None:
|
|
190
|
+
m = re.match(r"---\n(.*?)\n---\n?(.*)", text, re.S)
|
|
191
|
+
if not m:
|
|
192
|
+
return None
|
|
193
|
+
fields = {}
|
|
194
|
+
for line in m.group(1).splitlines():
|
|
195
|
+
key, sep, value = line.partition(":")
|
|
196
|
+
if sep:
|
|
197
|
+
fields[key.strip()] = value.strip()
|
|
198
|
+
name = path.stem if path else fields.get("name", "") # the file name, in case an edit changed the header
|
|
199
|
+
if not name or not fields.get("description"):
|
|
200
|
+
return None
|
|
201
|
+
kind = fields.get("type", "project")
|
|
202
|
+
return Note(
|
|
203
|
+
name=name,
|
|
204
|
+
type=kind if kind in TYPES else "project",
|
|
205
|
+
description=fields["description"],
|
|
206
|
+
details=m.group(2).strip(),
|
|
207
|
+
modified=fields.get("modified", ""),
|
|
208
|
+
scope=scope,
|
|
209
|
+
path=path,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def find_secret(text: str) -> str | None:
|
|
214
|
+
"""What kind of credential the text seems to contain, if any."""
|
|
215
|
+
for label, pattern in SECRET_PATTERNS:
|
|
216
|
+
if pattern.search(text):
|
|
217
|
+
return label
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def make_note(name: str, type: str, description: str, details: str = "", scope: str = "project") -> Note:
|
|
222
|
+
"""A checked note, ready to save. Raises NoteError explaining what's wrong."""
|
|
223
|
+
description = " ".join(str(description or "").split())
|
|
224
|
+
details = str(details or "").strip()
|
|
225
|
+
if type not in TYPES:
|
|
226
|
+
raise NoteError(f"type must be one of {', '.join(TYPES)}")
|
|
227
|
+
if scope not in SCOPES:
|
|
228
|
+
raise NoteError(f"scope must be one of {', '.join(SCOPES)}")
|
|
229
|
+
if not description:
|
|
230
|
+
raise NoteError("description is empty: write the fact in one line")
|
|
231
|
+
if len(description) > MAX_DESCRIPTION:
|
|
232
|
+
raise NoteError(
|
|
233
|
+
f"description is {len(description)} characters; keep it to one line under {MAX_DESCRIPTION} and put "
|
|
234
|
+
"the rest in details"
|
|
235
|
+
)
|
|
236
|
+
if len(details) > MAX_DETAILS:
|
|
237
|
+
raise NoteError(f"details are {len(details)} characters; keep them under {MAX_DETAILS}")
|
|
238
|
+
secret = find_secret(f"{name}\n{description}\n{details}")
|
|
239
|
+
if secret:
|
|
240
|
+
raise NoteError(f"not saved: it seems to contain {secret}. Never save secrets; describe where they live")
|
|
241
|
+
slug = slugify(name or description)
|
|
242
|
+
if not slug:
|
|
243
|
+
raise NoteError("name is empty: use a short kebab-case name such as use-pnpm")
|
|
244
|
+
return Note(slug, type, description, details, dt.date.today().isoformat(), scope)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class Memory:
|
|
248
|
+
"""The notes for one working directory: the user's own, and its repository's."""
|
|
249
|
+
|
|
250
|
+
def __init__(self, cwd: Path):
|
|
251
|
+
self.cwd = cwd
|
|
252
|
+
self.project = project_dir(cwd)
|
|
253
|
+
self.user = memory_dir() / "user"
|
|
254
|
+
|
|
255
|
+
def folder(self, scope: str) -> Path:
|
|
256
|
+
return self.user if scope == "user" else self.project
|
|
257
|
+
|
|
258
|
+
def notes(self, scope: str | None = None) -> list[Note]:
|
|
259
|
+
"""User notes first, then the repository's; newest first within each."""
|
|
260
|
+
found = []
|
|
261
|
+
for s in [scope] if scope else ["user", "project"]:
|
|
262
|
+
folder = self.folder(s)
|
|
263
|
+
if not folder.is_dir():
|
|
264
|
+
continue
|
|
265
|
+
mine = []
|
|
266
|
+
for f in folder.glob("*.md"):
|
|
267
|
+
if f.name == "MEMORY.md":
|
|
268
|
+
continue
|
|
269
|
+
try:
|
|
270
|
+
note = parse(f.read_text(errors="replace"), s, f)
|
|
271
|
+
except OSError:
|
|
272
|
+
continue
|
|
273
|
+
if note:
|
|
274
|
+
mine.append(note)
|
|
275
|
+
found += sorted(mine, key=lambda n: (n.modified, n.name), reverse=True)
|
|
276
|
+
return found
|
|
277
|
+
|
|
278
|
+
def find(self, name: str, scope: str | None = None) -> Note | None:
|
|
279
|
+
slug = slugify(name)
|
|
280
|
+
matches = [n for n in self.notes(scope) if n.name == slug]
|
|
281
|
+
# A repository note wins over a user note with the same name.
|
|
282
|
+
return next((n for n in matches if n.scope == "project"), matches[0] if matches else None)
|
|
283
|
+
|
|
284
|
+
def similar(self, note: Note) -> Note | None:
|
|
285
|
+
"""A note under another name in the same scope that says nearly the same thing."""
|
|
286
|
+
wanted = note.description.lower()
|
|
287
|
+
for other in self.notes(note.scope):
|
|
288
|
+
ratio = difflib.SequenceMatcher(None, wanted, other.description.lower()).ratio()
|
|
289
|
+
if other.name != note.name and ratio >= SIMILAR:
|
|
290
|
+
return other
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
def save(self, note: Note) -> tuple[Note, bool]:
|
|
294
|
+
"""Save a note, replacing the one with the same name. Returns (note, updated)."""
|
|
295
|
+
existing = self.find(note.name, note.scope)
|
|
296
|
+
if existing and not note.details and existing.description == note.description:
|
|
297
|
+
note = replace(note, details=existing.details)
|
|
298
|
+
folder = self.folder(note.scope)
|
|
299
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
path = folder / f"{note.name}.md"
|
|
301
|
+
_write_atomic(path, note.render())
|
|
302
|
+
self.write_index(note.scope)
|
|
303
|
+
return replace(note, path=path), existing is not None
|
|
304
|
+
|
|
305
|
+
def delete(self, note: Note) -> None:
|
|
306
|
+
if note.path:
|
|
307
|
+
note.path.unlink(missing_ok=True)
|
|
308
|
+
self.write_index(note.scope)
|
|
309
|
+
|
|
310
|
+
def write_index(self, scope: str) -> None:
|
|
311
|
+
"""MEMORY.md: a readable list of the notes in a folder. lcode reads the notes themselves."""
|
|
312
|
+
folder = self.folder(scope)
|
|
313
|
+
notes = self.notes(scope)
|
|
314
|
+
if not notes:
|
|
315
|
+
(folder / "MEMORY.md").unlink(missing_ok=True)
|
|
316
|
+
return
|
|
317
|
+
lines = ["# lcode memory", "", "Generated by lcode: edit or delete the notes, not this file.", ""]
|
|
318
|
+
lines += [n.line() for n in notes]
|
|
319
|
+
_write_atomic(folder / "MEMORY.md", "\n".join(lines) + "\n")
|
|
320
|
+
|
|
321
|
+
def prompt(self, context: int) -> str:
|
|
322
|
+
"""The memory section of the system prompt: the index, within its budget."""
|
|
323
|
+
budget = min(MAX_INDEX_CHARS, context * 3 // 20) # at most ~5% of the context window
|
|
324
|
+
notes = self.notes()
|
|
325
|
+
shown: list[Note] = []
|
|
326
|
+
used = 0
|
|
327
|
+
for note in sorted(notes, key=lambda n: n.modified, reverse=True):
|
|
328
|
+
cost = len(note.line()) + 1
|
|
329
|
+
if used + cost > budget:
|
|
330
|
+
continue
|
|
331
|
+
shown.append(note)
|
|
332
|
+
used += cost
|
|
333
|
+
parts = []
|
|
334
|
+
for scope, title in (("user", "For every repository:"), ("project", "For this repository:")):
|
|
335
|
+
lines = [n.line() for n in notes if n in shown and n.scope == scope]
|
|
336
|
+
if lines:
|
|
337
|
+
parts.append(title + "\n" + "\n".join(lines))
|
|
338
|
+
hidden = len(notes) - len(shown)
|
|
339
|
+
if hidden:
|
|
340
|
+
parts.append(f'({hidden} older note{"" if hidden == 1 else "s"} not shown: memory action "read" lists all)')
|
|
341
|
+
return PROMPT.format(index="\n".join(parts) if parts else "(none yet)")
|
|
342
|
+
|
|
343
|
+
def counts(self) -> tuple[int, int]:
|
|
344
|
+
return len(self.notes("project")), len(self.notes("user"))
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _write_atomic(path: Path, text: str) -> None:
|
|
348
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".partial")
|
|
349
|
+
try:
|
|
350
|
+
with os.fdopen(fd, "w") as f:
|
|
351
|
+
f.write(text)
|
|
352
|
+
os.replace(tmp, path)
|
|
353
|
+
except BaseException:
|
|
354
|
+
Path(tmp).unlink(missing_ok=True)
|
|
355
|
+
raise
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ----------------------------------------------------------------------------- the memory tool
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def run_tool(agent: Agent, action: str, name: str, type: str, description: str, details: str, scope: str) -> str:
|
|
362
|
+
memory = agent.memory()
|
|
363
|
+
if action == "read":
|
|
364
|
+
if not name:
|
|
365
|
+
notes = memory.notes()
|
|
366
|
+
return "\n".join(f"{n.line()} [{n.scope}]" for n in notes) if notes else "No notes yet."
|
|
367
|
+
note = memory.find(name)
|
|
368
|
+
if not note:
|
|
369
|
+
return f"Error: no note named {name!r}. Read without a name to list them."
|
|
370
|
+
return f"{note.line()} [{note.scope}]\n{note.details or '(no details)'}"
|
|
371
|
+
if action == "delete":
|
|
372
|
+
note = memory.find(name)
|
|
373
|
+
if not note:
|
|
374
|
+
return f"Error: no note named {name!r}."
|
|
375
|
+
if not approved(agent, f"Forget this {note.scope} note?", note):
|
|
376
|
+
return "The user kept the note."
|
|
377
|
+
memory.delete(note)
|
|
378
|
+
agent.console.print(Text(f" ⎿ forgot {note.name}", style="dim"))
|
|
379
|
+
return f"Deleted the note {note.name!r}."
|
|
380
|
+
if action != "save":
|
|
381
|
+
return 'Error: action must be "save", "read" or "delete"'
|
|
382
|
+
if not scope:
|
|
383
|
+
scope = "user" if type == "user" else "project"
|
|
384
|
+
try:
|
|
385
|
+
note = make_note(name, type, description, details, scope)
|
|
386
|
+
except NoteError as e:
|
|
387
|
+
return f"Error: {e}"
|
|
388
|
+
twin = memory.similar(note)
|
|
389
|
+
if twin:
|
|
390
|
+
return (
|
|
391
|
+
f"Error: the note {twin.name!r} already says nearly the same: {twin.description!r}. To change it, save "
|
|
392
|
+
f"under the name {twin.name!r}; if this is a different fact, make the description say how it differs."
|
|
393
|
+
)
|
|
394
|
+
if not approved(
|
|
395
|
+
agent, f"Remember this {'for every repository' if scope == 'user' else 'for this repository'}?", note
|
|
396
|
+
):
|
|
397
|
+
return "The user didn't want this saved. Don't try to save it again."
|
|
398
|
+
saved, updated = memory.save(note)
|
|
399
|
+
agent.console.print(Text(f" ⎿ {'updated' if updated else 'remembered'} {saved.name}", style="dim"))
|
|
400
|
+
return f"{'Updated' if updated else 'Saved'} the {saved.scope} note {saved.name!r}."
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def approved(agent: Agent, title: str, note: Note) -> bool:
|
|
404
|
+
if agent.settings.memory == "auto":
|
|
405
|
+
return True
|
|
406
|
+
body = Text(f"[{note.type}] {note.description}" + (f"\n{note.details}" if note.details else ""))
|
|
407
|
+
ok, _ = agent.perms.request("memory", "memory", title, body)
|
|
408
|
+
return ok
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ----------------------------------------------------------------------------- end-of-session reflection
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def worth_reflecting(messages: list[dict], start: int) -> bool:
|
|
415
|
+
from lcode.sessions import SUMMARY_PREFIX
|
|
416
|
+
|
|
417
|
+
new = messages[start:]
|
|
418
|
+
requests = sum(
|
|
419
|
+
1
|
|
420
|
+
for m in new
|
|
421
|
+
if m.get("role") == "user" and not str(m.get("content", "")).startswith(("[lcode]", SUMMARY_PREFIX))
|
|
422
|
+
)
|
|
423
|
+
tool_calls = sum(1 for m in new if m.get("role") == "tool")
|
|
424
|
+
return requests >= REFLECT_MIN_REQUESTS or (requests >= 1 and tool_calls >= REFLECT_MIN_TOOL_CALLS)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def parse_reflection(content: str) -> list[dict]:
|
|
428
|
+
"""The notes in the model's answer: {"notes": [...]}, a bare list, or JSON inside other text."""
|
|
429
|
+
data = None
|
|
430
|
+
for candidate in (content, *re.findall(r"\{.*\}|\[.*\]", content, re.S)):
|
|
431
|
+
try:
|
|
432
|
+
data = json.loads(candidate)
|
|
433
|
+
break
|
|
434
|
+
except (json.JSONDecodeError, TypeError):
|
|
435
|
+
continue
|
|
436
|
+
if isinstance(data, dict):
|
|
437
|
+
data = data.get("notes")
|
|
438
|
+
return [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def propose(agent: Agent) -> list[Note]:
|
|
442
|
+
"""Ask the model which facts from this session a future session should know."""
|
|
443
|
+
memory = agent.memory()
|
|
444
|
+
existing = "\n".join(f"{n.line()} [{n.scope}]" for n in memory.notes()) or "(none)"
|
|
445
|
+
payload = {
|
|
446
|
+
"model": agent.settings.model,
|
|
447
|
+
# The same messages and tools as the session, so Ollama reuses the cached prompt.
|
|
448
|
+
"messages": [
|
|
449
|
+
*agent.messages,
|
|
450
|
+
{"role": "user", "content": REFLECT_PROMPT.format(limit=MAX_REFLECTION_NOTES, existing=existing)},
|
|
451
|
+
],
|
|
452
|
+
"tools": agent.tool_schemas(),
|
|
453
|
+
"think": False,
|
|
454
|
+
"format": REFLECT_SCHEMA,
|
|
455
|
+
"keep_alive": agent.settings.keep_alive,
|
|
456
|
+
"options": agent.options(),
|
|
457
|
+
}
|
|
458
|
+
reply = agent.ollama.chat(payload)
|
|
459
|
+
content = (reply.get("message") or {}).get("content") or ""
|
|
460
|
+
notes = []
|
|
461
|
+
for item in parse_reflection(content)[:MAX_REFLECTION_NOTES]:
|
|
462
|
+
kind = str(item.get("type", "project"))
|
|
463
|
+
scope = str(item.get("scope") or ("user" if kind == "user" else "project"))
|
|
464
|
+
description = " ".join(str(item.get("description", "")).split())
|
|
465
|
+
details = str(item.get("details") or "")
|
|
466
|
+
if len(description) > MAX_DESCRIPTION:
|
|
467
|
+
description, details = description[: MAX_DESCRIPTION - 1].rstrip() + "…", (details or description)
|
|
468
|
+
try:
|
|
469
|
+
notes.append(make_note(str(item.get("name", "")), kind, description, details[:MAX_DETAILS], scope))
|
|
470
|
+
except NoteError:
|
|
471
|
+
continue # secrets and malformed notes are dropped
|
|
472
|
+
return [n for n in notes if not memory.similar(n)] # restatements of notes it already has
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def reflect(agent: Agent) -> None:
|
|
476
|
+
"""At the end of a session (or before compaction): propose notes, then save the ones the user accepts."""
|
|
477
|
+
if agent.settings.memory == "off" or not worth_reflecting(agent.messages, agent.reflected):
|
|
478
|
+
return
|
|
479
|
+
agent.reflected = len(agent.messages)
|
|
480
|
+
c = agent.console
|
|
481
|
+
from lcode.ollama import OllamaError
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
with c.status("[cyan]Looking for anything worth remembering…[/] [dim](Ctrl+C to skip)[/]", spinner="dots"):
|
|
485
|
+
notes = propose(agent)
|
|
486
|
+
except KeyboardInterrupt:
|
|
487
|
+
c.print("[dim]Skipped the memory check.[/]")
|
|
488
|
+
return
|
|
489
|
+
except OllamaError as e:
|
|
490
|
+
c.print(f"[dim]Couldn't check for anything worth remembering: {escape(str(e))}[/]")
|
|
491
|
+
return
|
|
492
|
+
if not notes:
|
|
493
|
+
return
|
|
494
|
+
memory = agent.memory()
|
|
495
|
+
if agent.settings.memory == "ask":
|
|
496
|
+
rows = []
|
|
497
|
+
for i, note in enumerate(notes, 1):
|
|
498
|
+
where = "every repository" if note.scope == "user" else "this repository"
|
|
499
|
+
update = memory.find(note.name, note.scope)
|
|
500
|
+
rows.append(
|
|
501
|
+
f"[cyan]{i}[/] [{note.type}] {escape(note.description)} [dim]({where}"
|
|
502
|
+
f"{', updates ' + escape(update.name) if update else ''})[/]"
|
|
503
|
+
+ (f"\n [dim]{escape(note.details)}[/]" if note.details else "")
|
|
504
|
+
)
|
|
505
|
+
c.print(Panel("\n".join(rows), title="Worth remembering?", title_align="left", border_style="blue"))
|
|
506
|
+
try:
|
|
507
|
+
answer = input(" Save them? [y]es / numbers to keep, e.g. 1,3 / [n]o: ").strip().lower()
|
|
508
|
+
except (EOFError, KeyboardInterrupt):
|
|
509
|
+
answer = "n"
|
|
510
|
+
if answer in ("n", "no", "none"):
|
|
511
|
+
return
|
|
512
|
+
if answer not in ("", "y", "yes", "a", "all"):
|
|
513
|
+
keep = {int(x) for x in re.findall(r"\d+", answer)}
|
|
514
|
+
notes = [n for i, n in enumerate(notes, 1) if i in keep]
|
|
515
|
+
for note in notes:
|
|
516
|
+
saved, updated = memory.save(note)
|
|
517
|
+
c.print(Text(f" {'Updated' if updated else 'Remembered'}: {saved.description}", style="dim"))
|
lcode/permissions.py
CHANGED
|
@@ -67,11 +67,16 @@ class Permissions:
|
|
|
67
67
|
self.mode = PERMISSION_MODES[(PERMISSION_MODES.index(self.mode) + 1) % len(PERMISSION_MODES)]
|
|
68
68
|
|
|
69
69
|
def request(self, key: str, kind: str, title: str, body: RenderableType) -> tuple[bool, str]:
|
|
70
|
-
"""Ask the user. kind is 'edit', 'bash', 'web' or '
|
|
71
|
-
|
|
70
|
+
"""Ask the user. kind is 'edit', 'bash', 'web', 'mcp' or 'memory'. Returns (allowed, message for the model).
|
|
71
|
+
|
|
72
|
+
Memory notes follow the memory setting rather than the mode: with `memory = ask`, even yolo asks.
|
|
73
|
+
"""
|
|
74
|
+
if key in self.always or (self.mode == "yolo" and kind != "memory"):
|
|
75
|
+
return True, ""
|
|
76
|
+
if kind == "edit" and self.mode == "auto-edit":
|
|
72
77
|
return True, ""
|
|
73
78
|
self.console.print(Panel(body, title=title, title_align="left", border_style="yellow"))
|
|
74
|
-
scope = "file edits"
|
|
79
|
+
scope = {"edit": "file edits", "memory": "memory notes"}.get(kind) or key.split(":", 1)[-1]
|
|
75
80
|
if kind == "mcp":
|
|
76
81
|
scope = scope.replace(":", " › ", 1)
|
|
77
82
|
try:
|
lcode/repl.py
CHANGED
|
@@ -6,6 +6,9 @@ import html
|
|
|
6
6
|
import json
|
|
7
7
|
import os
|
|
8
8
|
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
9
12
|
import threading
|
|
10
13
|
from pathlib import Path
|
|
11
14
|
|
|
@@ -22,6 +25,7 @@ from rich.table import Table
|
|
|
22
25
|
from rich.text import Text
|
|
23
26
|
|
|
24
27
|
from lcode import __version__, catalog, limits, sessions, web
|
|
28
|
+
from lcode import memory as memory_notes
|
|
25
29
|
from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
|
|
26
30
|
from lcode.catalog import MIN_USEFUL_CONTEXT
|
|
27
31
|
from lcode.checkpoints import Checkpoint, CheckpointError, Restore
|
|
@@ -39,6 +43,8 @@ COMMANDS = {
|
|
|
39
43
|
"/undo": "Undo the file changes of the last request (lcode saves a checkpoint before changing files)",
|
|
40
44
|
"/rewind": "Go back to before an earlier request: its files, and optionally the conversation",
|
|
41
45
|
"/checkpoints": "List the requests that changed files, and which files",
|
|
46
|
+
"/remember": "Save a note for later sessions, e.g. /remember use pnpm, not npm (-g: for every repository)",
|
|
47
|
+
"/memory": "Notes lcode remembers: /memory, /memory show|edit|delete <number or name>, /memory path",
|
|
42
48
|
"/sandbox": "Shell-command sandbox: status, or /sandbox network on|off",
|
|
43
49
|
"/mcp": "MCP servers and their tools: /mcp, /mcp tools NAME, /mcp login NAME, /mcp restart NAME",
|
|
44
50
|
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
@@ -149,6 +155,7 @@ def banner(agent: Agent) -> None:
|
|
|
149
155
|
if agent.sandbox
|
|
150
156
|
else ""
|
|
151
157
|
)
|
|
158
|
+
+ (f"[dim]memory[/] {memory_status(agent)}\n" if s.memory != "off" else "")
|
|
152
159
|
+ "\n"
|
|
153
160
|
"[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
|
|
154
161
|
),
|
|
@@ -314,6 +321,129 @@ def mcp_command(agent: Agent, arg: str) -> None:
|
|
|
314
321
|
agent.prepare_mcp() # the model sees the server's tools from the next request
|
|
315
322
|
|
|
316
323
|
|
|
324
|
+
def memory_status(agent: Agent) -> str:
|
|
325
|
+
project, user = agent.memory().counts()
|
|
326
|
+
if not (project or user):
|
|
327
|
+
return f"{agent.settings.memory} · no notes yet [dim](/remember)[/]"
|
|
328
|
+
parts = [f"{project} for this repository"] if project else []
|
|
329
|
+
parts += [f"{user} for every repository"] if user else []
|
|
330
|
+
total = project + user
|
|
331
|
+
return f"{agent.settings.memory} · {total} note{'' if total == 1 else 's'}: {', '.join(parts)} [dim](/memory)[/]"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def memory_off(agent: Agent) -> bool:
|
|
335
|
+
if agent.settings.memory != "off":
|
|
336
|
+
return False
|
|
337
|
+
agent.console.print(
|
|
338
|
+
"Memory is off in this session. Turn it on with: lcode config set memory ask (or drop --no-memory)"
|
|
339
|
+
)
|
|
340
|
+
return True
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def remember_command(agent: Agent, arg: str) -> None:
|
|
344
|
+
"""/remember [-g] [type:] text — save a note the user writes, without asking the model."""
|
|
345
|
+
c = agent.console
|
|
346
|
+
if memory_off(agent):
|
|
347
|
+
return
|
|
348
|
+
words = arg.split()
|
|
349
|
+
scope = "project"
|
|
350
|
+
if words and words[0] in ("-g", "--global", "--user"):
|
|
351
|
+
scope, words = "user", words[1:]
|
|
352
|
+
text = " ".join(words)
|
|
353
|
+
if not text:
|
|
354
|
+
c.print(
|
|
355
|
+
"Usage: /remember <fact>, e.g. /remember the staging database needs the VPN. "
|
|
356
|
+
"Add -g for a note for every repository, and start with feedback:, reference: or user: to set its type."
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
kind = "user" if scope == "user" else "project"
|
|
360
|
+
m = re.match(r"(feedback|project|reference|user):\s*(.*)", text, re.I | re.S)
|
|
361
|
+
if m:
|
|
362
|
+
kind, text = m.group(1).lower(), m.group(2)
|
|
363
|
+
description, details = text, ""
|
|
364
|
+
if len(text) > memory_notes.MAX_DESCRIPTION:
|
|
365
|
+
description, details = text[: memory_notes.MAX_DESCRIPTION - 1].rstrip() + "…", text
|
|
366
|
+
try:
|
|
367
|
+
note = memory_notes.make_note(memory_notes.slugify(description), kind, description, details, scope)
|
|
368
|
+
except memory_notes.NoteError as e:
|
|
369
|
+
c.print(f"[red]{escape(str(e))}[/]")
|
|
370
|
+
return
|
|
371
|
+
saved, updated = agent.memory().save(note)
|
|
372
|
+
where = "every repository" if scope == "user" else "this repository"
|
|
373
|
+
c.print(f"[green]{'Updated' if updated else 'Remembered'} for {where}:[/] {escape(saved.description)}")
|
|
374
|
+
agent.messages.append(
|
|
375
|
+
{"role": "user", "content": f"[lcode] The user saved a note that later sessions will see: {saved.description}"}
|
|
376
|
+
)
|
|
377
|
+
agent.save()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def memory_command(agent: Agent, arg: str) -> None:
|
|
381
|
+
c = agent.console
|
|
382
|
+
if memory_off(agent):
|
|
383
|
+
return
|
|
384
|
+
memory = agent.memory()
|
|
385
|
+
notes = memory.notes()
|
|
386
|
+
action, _, target = arg.partition(" ")
|
|
387
|
+
target = target.strip()
|
|
388
|
+
if action == "path":
|
|
389
|
+
c.print(f"For this repository: {memory.project}\nFor every repository: {memory.user}")
|
|
390
|
+
return
|
|
391
|
+
if action in ("", "list"):
|
|
392
|
+
if not notes:
|
|
393
|
+
c.print(
|
|
394
|
+
"No notes yet. lcode saves what later sessions should know: the model with its memory tool, you "
|
|
395
|
+
"with /remember, and a short check when a session ends."
|
|
396
|
+
)
|
|
397
|
+
return
|
|
398
|
+
table = Table(title="Memory", title_justify="left", header_style="bold")
|
|
399
|
+
table.add_column("#", justify="right", style="cyan")
|
|
400
|
+
table.add_column("For")
|
|
401
|
+
table.add_column("Type")
|
|
402
|
+
table.add_column("Note")
|
|
403
|
+
table.add_column("Updated", no_wrap=True)
|
|
404
|
+
for i, note in enumerate(notes, 1):
|
|
405
|
+
table.add_row(
|
|
406
|
+
str(i),
|
|
407
|
+
"every repo" if note.scope == "user" else "this repo",
|
|
408
|
+
note.type,
|
|
409
|
+
f"{escape(note.description)} [dim]{escape(note.name)}[/]",
|
|
410
|
+
note.modified,
|
|
411
|
+
)
|
|
412
|
+
c.print(table)
|
|
413
|
+
c.print("[dim]/memory show|edit|delete <number or name> · the model sees these at the start of a session[/]")
|
|
414
|
+
return
|
|
415
|
+
if action not in ("show", "edit", "delete", "forget"):
|
|
416
|
+
c.print("[yellow]Use /memory, /memory show|edit|delete <number or name> or /memory path.[/]")
|
|
417
|
+
return
|
|
418
|
+
note = notes[int(target) - 1] if target.isdigit() and 1 <= int(target) <= len(notes) else None
|
|
419
|
+
note = note or (memory.find(target) if target else None)
|
|
420
|
+
if note is None or note.path is None:
|
|
421
|
+
c.print(f"[yellow]No note {escape(target)!r}. /memory lists them.[/]" if target else "[yellow]Which note?[/]")
|
|
422
|
+
return
|
|
423
|
+
if action == "show":
|
|
424
|
+
where = "every repository" if note.scope == "user" else "this repository"
|
|
425
|
+
c.print(
|
|
426
|
+
Panel(
|
|
427
|
+
escape(note.render()), title=f"{escape(note.name)} · {where}", title_align="left", border_style="blue"
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
c.print(f"[dim]{escape(str(note.path))}[/]")
|
|
431
|
+
elif action == "edit":
|
|
432
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("nano" if shutil.which("nano") else "vi")
|
|
433
|
+
try:
|
|
434
|
+
subprocess.call([*shlex.split(editor), str(note.path)])
|
|
435
|
+
except OSError as e:
|
|
436
|
+
c.print(f"[red]Can't start the editor {escape(editor)}: {escape(str(e))}[/]")
|
|
437
|
+
return
|
|
438
|
+
if memory_notes.parse(note.path.read_text(errors="replace"), note.scope, note.path) is None:
|
|
439
|
+
c.print(f"[yellow]{escape(str(note.path))} no longer has a name and description, so lcode ignores it.[/]")
|
|
440
|
+
memory.write_index(note.scope)
|
|
441
|
+
c.print("[dim]Saved. The model sees the change from the next session.[/]")
|
|
442
|
+
elif ask_yes(f"Delete the note '{note.description}'?"):
|
|
443
|
+
memory.delete(note)
|
|
444
|
+
c.print(f"Deleted {escape(note.name)}. [dim]The model sees the change from the next session.[/]")
|
|
445
|
+
|
|
446
|
+
|
|
317
447
|
def ask_yes(question: str) -> bool:
|
|
318
448
|
try:
|
|
319
449
|
return input(f" {question} [y/N] ").strip().lower() in ("y", "yes")
|
|
@@ -450,6 +580,7 @@ def restore_checkpoint(agent: Agent, target: Checkpoint, rewind: bool) -> None:
|
|
|
450
580
|
c.print(f"[green]Done:[/] {escape(file_summary(plan, limit=8))}.")
|
|
451
581
|
if truncate:
|
|
452
582
|
agent.messages = agent.messages[:index]
|
|
583
|
+
agent.reflected = min(agent.reflected, index)
|
|
453
584
|
agent.ctx_used = sum(len(json.dumps(m)) for m in agent.messages) // 3
|
|
454
585
|
c.print(f"The conversation is back to before request {target.n} as well.")
|
|
455
586
|
elif plan.restore or plan.remove:
|
|
@@ -525,6 +656,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
525
656
|
)
|
|
526
657
|
c.print(Panel(f"{rows}\n\n [dim]{keys}[/]", title="lcode commands", border_style="cyan"))
|
|
527
658
|
elif cmd == "/clear":
|
|
659
|
+
memory_notes.reflect(agent)
|
|
528
660
|
agent.new_session()
|
|
529
661
|
c.print("[green]Started a new conversation.[/] The previous one is saved; /resume brings it back.")
|
|
530
662
|
elif cmd == "/rename":
|
|
@@ -544,6 +676,10 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
544
676
|
rewind_command(agent, arg)
|
|
545
677
|
elif cmd == "/checkpoints":
|
|
546
678
|
print_checkpoints(agent)
|
|
679
|
+
elif cmd == "/remember":
|
|
680
|
+
remember_command(agent, arg)
|
|
681
|
+
elif cmd == "/memory":
|
|
682
|
+
memory_command(agent, arg)
|
|
547
683
|
elif cmd == "/mcp":
|
|
548
684
|
mcp_command(agent, arg)
|
|
549
685
|
elif cmd == "/sandbox":
|
|
@@ -727,6 +863,7 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
727
863
|
continue
|
|
728
864
|
agent.console.print(Rule(style="dim"))
|
|
729
865
|
run_safely(agent, line)
|
|
866
|
+
memory_notes.reflect(agent)
|
|
730
867
|
agent.console.print("[dim]Bye.[/]")
|
|
731
868
|
|
|
732
869
|
|
lcode/tools.py
CHANGED
|
@@ -173,7 +173,34 @@ VIEW_IMAGE_SCHEMA = _fn(
|
|
|
173
173
|
},
|
|
174
174
|
["path"],
|
|
175
175
|
)
|
|
176
|
-
|
|
176
|
+
MEMORY_SCHEMA = _fn(
|
|
177
|
+
"memory",
|
|
178
|
+
"Your memory across sessions. save: keep a fact that a future session needs and that isn't in the code, git "
|
|
179
|
+
"history or AGENTS.md (one fact per note; saving under an existing name updates that note). delete: remove a "
|
|
180
|
+
"note that turned out wrong. read: a note's details, or the list of all notes when no name is given.",
|
|
181
|
+
{
|
|
182
|
+
"action": {"type": "string", "enum": ["save", "read", "delete"]},
|
|
183
|
+
"name": {"type": "string", "description": "Short kebab-case name, e.g. use-pnpm"},
|
|
184
|
+
"type": {
|
|
185
|
+
"type": "string",
|
|
186
|
+
"enum": ["feedback", "project", "reference", "user"],
|
|
187
|
+
"description": "save: feedback = the user's corrections and preferences; project = decisions, "
|
|
188
|
+
"constraints, ongoing work; reference = where things live outside the repository; user = who the "
|
|
189
|
+
"user is",
|
|
190
|
+
},
|
|
191
|
+
"description": {"type": "string", "description": "save: the fact, in one line"},
|
|
192
|
+
"details": {"type": "string", "description": "save (optional): why, and how to apply it"},
|
|
193
|
+
"scope": {
|
|
194
|
+
"type": "string",
|
|
195
|
+
"enum": ["project", "user"],
|
|
196
|
+
"description": "save: project (default) = this repository only; user = every repository",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
["action"],
|
|
200
|
+
)
|
|
201
|
+
TOOL_NAMES = {
|
|
202
|
+
s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA, MEMORY_SCHEMA]
|
|
203
|
+
}
|
|
177
204
|
|
|
178
205
|
|
|
179
206
|
# ----------------------------------------------------------------------------- helpers
|
|
@@ -340,6 +367,16 @@ class Toolbox:
|
|
|
340
367
|
valid = ", ".join(p if params[p].default is inspect.Parameter.empty else f"{p} (optional)" for p in params)
|
|
341
368
|
return f"{'; '.join(parts)}. Valid arguments: {valid}."
|
|
342
369
|
|
|
370
|
+
# -- memory
|
|
371
|
+
def t_memory(
|
|
372
|
+
self, action: str, name: str = "", type: str = "", description: str = "", details: str = "", scope: str = ""
|
|
373
|
+
) -> str:
|
|
374
|
+
if self.agent.settings.memory == "off":
|
|
375
|
+
raise ToolError("memory is turned off")
|
|
376
|
+
from lcode.memory import run_tool
|
|
377
|
+
|
|
378
|
+
return run_tool(self.agent, action, name, type, description, details, scope)
|
|
379
|
+
|
|
343
380
|
# -- images
|
|
344
381
|
def t_view_image(self, path: str, question: str = "") -> str:
|
|
345
382
|
p = self.path(path)
|
|
@@ -365,6 +402,11 @@ class Toolbox:
|
|
|
365
402
|
if not ok:
|
|
366
403
|
return feedback
|
|
367
404
|
if mcp.needs_gpu(state, tool):
|
|
405
|
+
# The tool must finish before lcode's model comes back: a job left running in the
|
|
406
|
+
# background would compete with it for the GPU.
|
|
407
|
+
properties = (tool.get("inputSchema") or {}).get("properties") or {}
|
|
408
|
+
if (properties.get("wait") or {}).get("type") == "boolean" and arguments.get("wait") is not True:
|
|
409
|
+
arguments = {**arguments, "wait": True}
|
|
368
410
|
freed = self.agent.free_gpu()
|
|
369
411
|
if freed:
|
|
370
412
|
self.console.print(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -82,6 +82,9 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
82
82
|
Podman) that only sees your project, without network access unless you allow it.
|
|
83
83
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
84
84
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
85
|
+
- **Remembers what matters.** Corrections, decisions and where things live carry over to the next
|
|
86
|
+
session as short notes, saved by the model, by you (`/remember`) or by a quick check at the end
|
|
87
|
+
of a session. Not whole conversations, and never secrets.
|
|
85
88
|
- **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
|
|
86
89
|
with tool calling.
|
|
87
90
|
- **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
|
|
@@ -189,6 +192,10 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
189
192
|
|
|
190
193
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
191
194
|
|
|
195
|
+
- [Parallel subagents](https://github.com/nasser1941/lcode/issues/40) with their own context
|
|
196
|
+
- [Plan mode](https://github.com/nasser1941/lcode/issues/41) and [custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
|
|
197
|
+
- [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
|
|
198
|
+
- Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
|
|
192
199
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
193
200
|
|
|
194
201
|
## Contributing
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=AYDtOJESsR1DlJ0ZwkCOtt-VYOGMMNpCJq6WX274Lz8,117
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
3
|
+
lcode/agent.py,sha256=oqG_t5SgtaZu_rvceNB_E15okpTpC1a4_HN30OYaLwY,33570
|
|
4
4
|
lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
|
|
5
5
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
6
6
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
7
|
-
lcode/cli.py,sha256=
|
|
8
|
-
lcode/config.py,sha256=
|
|
7
|
+
lcode/cli.py,sha256=Au73avRLxEkq0gnvJ2FjVGhwRw7EkPGkAdOaqpu9Ozw,28040
|
|
8
|
+
lcode/config.py,sha256=E326xMjcoHCP88MfnG_dxmNRlUYHm4A1OwQQoPtgg7g,7518
|
|
9
9
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
10
10
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
11
|
+
lcode/memory.py,sha256=-gHSbJ4kradOzhlHf38pHzZkb-qe6HQTN1lr2CJ2GJQ,22066
|
|
11
12
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
12
13
|
lcode/ollama.py,sha256=8zxJcMhcxKF3L0vdgrzcoXAj_4MvbybVDSRPbLek0QE,6892
|
|
13
|
-
lcode/permissions.py,sha256=
|
|
14
|
+
lcode/permissions.py,sha256=WhbgvhS6GTpuBbvbpQPHd3F65MtdNlKYjio58lPIyFY,4192
|
|
14
15
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
15
|
-
lcode/repl.py,sha256=
|
|
16
|
+
lcode/repl.py,sha256=Xke23adGRnGNbqwsjCQ-7rVM2rICoy-UxG38wi9-YxM,36536
|
|
16
17
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
17
18
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
18
|
-
lcode/tools.py,sha256=
|
|
19
|
+
lcode/tools.py,sha256=PVKmnFtXL0mvx7HV0nPLgNuUb4sUjbiGPNpXp37gVOs,32428
|
|
19
20
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
20
21
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
21
22
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
@@ -29,8 +30,8 @@ lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
|
|
|
29
30
|
lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
|
|
30
31
|
lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
|
|
31
32
|
lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
32
|
-
lcode_cli-0.
|
|
33
|
-
lcode_cli-0.
|
|
34
|
-
lcode_cli-0.
|
|
35
|
-
lcode_cli-0.
|
|
36
|
-
lcode_cli-0.
|
|
33
|
+
lcode_cli-0.7.0.dist-info/METADATA,sha256=1tegGLTvWCWivTaFxFp0kjftvUML1iK0hEy3lgzIVPw,11143
|
|
34
|
+
lcode_cli-0.7.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
35
|
+
lcode_cli-0.7.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
|
|
36
|
+
lcode_cli-0.7.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
37
|
+
lcode_cli-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|