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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.7.0"
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}{memory}"""
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
- memory = ""
191
+ project = ""
186
192
  for name in PROJECT_FILES:
187
193
  f = self.cwd / name
188
194
  if f.is_file():
189
- memory = f"\n# Project instructions ({name})\n{truncate(f.read_text(errors='replace'), 20_000)}\n"
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
- memory=memory,
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 'mcp'. Returns (allowed, message for the model)."""
71
- if self.mode == "yolo" or key in self.always or (kind == "edit" and self.mode == "auto-edit"):
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" if kind == "edit" else key.split(":", 1)[-1]
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
- TOOL_NAMES = {s["function"]["name"] for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA]}
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.6.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=FWZV2PnebwvlC4fL-VRqLfpH2njKjqCUWaGd0s-R-5c,117
1
+ lcode/__init__.py,sha256=AYDtOJESsR1DlJ0ZwkCOtt-VYOGMMNpCJq6WX274Lz8,117
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=AhUf1l0OEjpizU74HI7yfdRz0q_UAnqFddbyQwdmFx0,32256
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=53MucKCHe9NreYXwDaMEwfHtPS-26kxj7Xg3Bl4yoR8,27506
8
- lcode/config.py,sha256=VyRHq3ElCGmTQqTdtcGRHGuselm2U8-04ZC3tnEQ6L8,7063
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=OcWUDLrSoohUWHDUQ3UuBE3zI2JnBnOPizLE3bIgqyU,3982
14
+ lcode/permissions.py,sha256=WhbgvhS6GTpuBbvbpQPHd3F65MtdNlKYjio58lPIyFY,4192
14
15
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
15
- lcode/repl.py,sha256=2p6UG1xCmn85YEYcWUtgfoLJc3rHLaGLb_VV-RvzYvw,30332
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=iwt7UINg7xkwz1k_BAXFM-siA6KxC6LHuAjaomBHoRU,30287
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.6.0.dist-info/METADATA,sha256=oMYnPQJlwjwLZls7GP4mxJkTRLmWM0LaVzAsyQdcqiM,10251
33
- lcode_cli-0.6.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
34
- lcode_cli-0.6.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
35
- lcode_cli-0.6.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
36
- lcode_cli-0.6.0.dist-info/RECORD,,
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,,