seedcode-cli 6.1.5__py3-none-any.whl → 6.2.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.
seedcode/__init__.py CHANGED
@@ -6,7 +6,7 @@ Plant ideas. Grow code.
6
6
  # Single source of truth for the application version. Every consumer reads it:
7
7
  # the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
8
8
  # Windows installer metadata, the GitHub release tag, and the WinGet manifest.
9
- __version__ = "6.1.5"
9
+ __version__ = "6.2.0"
10
10
  __author__ = "Al shahriar sowan"
11
11
  __publisher__ = "Eagox Studio"
12
12
 
@@ -0,0 +1,12 @@
1
+ """Embedded default OpenRouter credential (v6.2.0 build-time artifact).
2
+
3
+ This file is NOT committed to source control. The release build generates
4
+ ``seedcode/_default_key.py`` from the local ``.env`` (see
5
+ ``scripts/windows/embed_default_key.py``). Source checkouts without that
6
+ generated file fall back to the empty default below, so behavior is
7
+ unchanged for developers and CI.
8
+ """
9
+
10
+ DEFAULT_OPENROUTER_API_KEY = ""
11
+ DEFAULT_API_ENABLED = False
12
+ DEFAULT_API_NOTE = "embedded default key unavailable (development checkout)"
seedcode/app.py CHANGED
@@ -52,13 +52,17 @@ _KEY_ACTIONS = {
52
52
  }
53
53
 
54
54
 
55
- def _exit_application(reason: str) -> None:
55
+ def _exit_application(ui: "UI", reason: str) -> None:
56
56
  """The single, explicit application-exit decision.
57
57
 
58
58
  Called only from unambiguous user actions (the menu's Exit item, leaving
59
59
  the menu with Esc/Ctrl+C/Ctrl+D). Marks the lifecycle, enters SHUTDOWN
60
60
  (running teardown hooks), and returns; :func:`run` then unwinds and
61
61
  ``main`` finishes normally. Task completion and errors never reach here.
62
+
63
+ ``ui`` is passed in explicitly by :func:`run` — this module has no global
64
+ UI instance, and referencing one from here was the root cause of the
65
+ ``NameError: name 'ui' is not defined`` users saw on Exit.
62
66
  """
63
67
  lc = lifecycle()
64
68
  lc.request_exit(reason)
@@ -227,6 +231,21 @@ def _handle_agent(ui: UI, agent: AgentEngine, history: HistoryStore, text: str)
227
231
  else:
228
232
  ui.dim("(no response)")
229
233
  history.save(agent.transcript)
234
+ # v6.2.0: Code Mode sessions leave a compact summary in .seedcode/sessions.
235
+ try:
236
+ from . import codemode_state as _cms
237
+
238
+ state = _cms.codemode_state()
239
+ if state.enabled and state.store is not None:
240
+ state.store.save_session_summary({
241
+ "goal": text[:200],
242
+ "outcome": (final.strip() or "(no response)")[:400],
243
+ "tool_calls": sum(
244
+ 1 for m in agent.messages if m.role == "tool"
245
+ ),
246
+ })
247
+ except Exception:
248
+ pass
230
249
 
231
250
 
232
251
  def _make_agent(ui: UI, config: AppConfig) -> AgentEngine:
@@ -338,6 +357,14 @@ def _chat_loop(
338
357
  # prompt and permission gates reflect both).
339
358
  agent: AgentEngine | None = None
340
359
  agent_perm = config.permission_mode
360
+ # v6.2.0: track Code Mode so a /codemode toggle rebuilds the agent with
361
+ # workspace context (the engine is constructed lazily per turn).
362
+ try:
363
+ from . import codemode_state as _cms
364
+
365
+ agent_codemode = _cms.enabled
366
+ except Exception:
367
+ agent_codemode = False
341
368
 
342
369
  while True:
343
370
  try:
@@ -389,9 +416,20 @@ def _chat_loop(
389
416
  # prompts again. A task can never end the app.
390
417
  with lifecycle().task_span():
391
418
  if config.agent_mode:
392
- if agent is None or agent_perm != config.permission_mode:
419
+ try:
420
+ from . import codemode_state as _cms
421
+
422
+ codemode_now = _cms.enabled
423
+ except Exception:
424
+ codemode_now = False
425
+ if (
426
+ agent is None
427
+ or agent_perm != config.permission_mode
428
+ or agent_codemode != codemode_now
429
+ ):
393
430
  agent = _make_agent(ui, config)
394
431
  agent_perm = config.permission_mode
432
+ agent_codemode = codemode_now
395
433
  _handle_agent(ui, agent, history, text)
396
434
  else:
397
435
  _handle_chat(ui, engine, history, text)
@@ -477,7 +515,7 @@ def run(ui: UI) -> None:
477
515
  choice = _main_menu(config)
478
516
  except (KeyboardInterrupt, EOFError):
479
517
  # The user explicitly left the menu — the app-exit decision.
480
- _exit_application("menu interrupt")
518
+ _exit_application(ui, "menu interrupt")
481
519
  return
482
520
 
483
521
  try:
@@ -499,7 +537,7 @@ def run(ui: UI) -> None:
499
537
  elif choice == "about":
500
538
  show_about(ui, config)
501
539
  elif choice in ("exit", None):
502
- _exit_application("menu exit")
540
+ _exit_application(ui, "menu exit")
503
541
  return
504
542
  except (KeyboardInterrupt, EOFError):
505
543
  ui.dim("Cancelled.")
@@ -19,7 +19,8 @@ from __future__ import annotations
19
19
 
20
20
  import os
21
21
  import shutil
22
- from dataclasses import dataclass, field
22
+ import time
23
+ from dataclasses import dataclass, field, replace
23
24
  from pathlib import Path
24
25
  from typing import Any
25
26
 
@@ -50,7 +51,32 @@ _START_MENU_DIRS = (
50
51
  )
51
52
 
52
53
 
54
+ # --- v6.2.0 discovery caches ----------------------------------------------------
55
+ # Start Menu scans (rglob over two trees) and the registry walks (App Paths +
56
+ # uninstall keys) are the slow part of discovery; installed software changes
57
+ # on the scale of minutes, not milliseconds, so caching them briefly is safe.
58
+ # Entries are copied on return so callers (find_app resolves the exe on an
59
+ # uninstall entry) can never mutate the cached record.
60
+ _START_MENU_TTL_S = 5.0
61
+ _REGISTRY_TTL_S = 30.0
62
+ _start_menu_cache: tuple[float, list[Path]] | None = None
63
+ _app_paths_cache: tuple[float, dict[str, AppInfo]] | None = None
64
+ _uninstall_cache: tuple[float, dict[str, AppInfo]] | None = None
65
+
66
+
67
+ def _reset_discovery_cache() -> None:
68
+ """Drop all discovery caches (tests, session teardown)."""
69
+ global _start_menu_cache, _app_paths_cache, _uninstall_cache
70
+ _start_menu_cache = None
71
+ _app_paths_cache = None
72
+ _uninstall_cache = None
73
+
74
+
53
75
  def _iter_start_menu() -> list[Path]:
76
+ global _start_menu_cache
77
+ now = time.monotonic()
78
+ if _start_menu_cache is not None and now - _start_menu_cache[0] < _START_MENU_TTL_S:
79
+ return list(_start_menu_cache[1])
54
80
  shortcuts: list[Path] = []
55
81
  for base in _START_MENU_DIRS:
56
82
  try:
@@ -58,7 +84,8 @@ def _iter_start_menu() -> list[Path]:
58
84
  shortcuts.extend(base.rglob("*.lnk"))
59
85
  except OSError:
60
86
  continue
61
- return shortcuts
87
+ _start_menu_cache = (now, shortcuts)
88
+ return list(shortcuts)
62
89
 
63
90
 
64
91
  def _shortcut_display_name(path: Path) -> str:
@@ -76,6 +103,9 @@ def _match_name(haystack: str, needle: str) -> bool:
76
103
  # --- source 2: App Paths -----------------------------------------------------------
77
104
  def _app_paths() -> dict[str, AppInfo]:
78
105
  """``App Paths`` registrations: exe name -> full path (best-effort)."""
106
+ global _app_paths_cache
107
+ if _app_paths_cache is not None and time.monotonic() - _app_paths_cache[0] < _REGISTRY_TTL_S:
108
+ return {k: replace(v) for k, v in _app_paths_cache[1].items()}
79
109
  found: dict[str, AppInfo] = {}
80
110
  if os.name != "nt":
81
111
  return found
@@ -108,12 +138,16 @@ def _app_paths() -> dict[str, AppInfo]:
108
138
  )
109
139
  except ImportError:
110
140
  pass
141
+ _app_paths_cache = (time.monotonic(), found)
111
142
  return found
112
143
 
113
144
 
114
145
  # --- source 3: uninstall registrations ----------------------------------------------
115
146
  def _uninstall_apps() -> dict[str, AppInfo]:
116
147
  """Display-name/install-location entries from the uninstall keys."""
148
+ global _uninstall_cache
149
+ if _uninstall_cache is not None and time.monotonic() - _uninstall_cache[0] < _REGISTRY_TTL_S:
150
+ return {k: replace(v) for k, v in _uninstall_cache[1].items()}
117
151
  found: dict[str, AppInfo] = {}
118
152
  if os.name != "nt":
119
153
  return found
@@ -158,6 +192,7 @@ def _uninstall_apps() -> dict[str, AppInfo]:
158
192
  found.setdefault(name.lower(), entry)
159
193
  except ImportError:
160
194
  pass
195
+ _uninstall_cache = (time.monotonic(), found)
161
196
  return found
162
197
 
163
198
 
Binary file
seedcode/branding.py ADDED
@@ -0,0 +1,90 @@
1
+ """Canonical Seed Code brand artwork — single source of truth.
2
+
3
+ The official Seed Code mark is the 16x16 pixel-art "sprouting seed" on a
4
+ dark rounded tile (the Seed Green identity). This module owns the grid and
5
+ the exact palette so every surface renders identical art:
6
+
7
+ * ``seedcode/ui/logo.py`` — terminal rendering (half-block pixels)
8
+ * ``scripts/windows/build_assets.py`` — ``seedcode.ico``, the Inno wizard
9
+ bitmaps, the canonical ``assets/logo.png`` and the preview render
10
+
11
+ Stdlib only (struct/zlib): safe to import from anywhere in the package,
12
+ costs nothing at startup, and keeps the brand byte-reproducible.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ import zlib
19
+
20
+ GRID = 16 # the mark is authored on a 16x16 grid
21
+
22
+ # --- palette (mirrors seedcode/ui/theme.py) ---------------------------------
23
+ ACCENT = (123, 237, 159, 255) # Soft Green — leaf highlight
24
+ PRIMARY = (46, 204, 113, 255) # Seed Green — leaf body
25
+ DARK = (27, 122, 67, 255) # seed body
26
+ TILE_BG = (13, 17, 23, 255) # dark rounded tile behind the mark
27
+ CLEAR = (0, 0, 0, 0) # outside the rounded tile (transparent)
28
+
29
+ # --- the mark: a sprouting seed, 16x16 --------------------------------------
30
+ ART: list[str] = [
31
+ "................",
32
+ "................",
33
+ "...LL.....GG....",
34
+ "..LLLL...GGGG...",
35
+ ".LLLLLL.GGGGG...",
36
+ ".LLLLLL.GGGGGG..",
37
+ "..LLLLL.GGGGG...",
38
+ "...LLLL.GGGG....",
39
+ ".....LL.GG......",
40
+ "......LGG.......",
41
+ ".......G........",
42
+ ".......G........",
43
+ "......DDD.......",
44
+ ".....DDDDD......",
45
+ ".....DDDDD......",
46
+ "......DDD.......",
47
+ ]
48
+ INK = {"L": ACCENT, "G": PRIMARY, "D": DARK}
49
+
50
+ Pixel = tuple[int, int, int, int]
51
+
52
+
53
+ def logo_pixels(size: int = GRID) -> list[list[Pixel]]:
54
+ """Render the mark at ``size`` px: rounded dark tile + scaled pixel art."""
55
+ radius = size * 3 // 16
56
+ grid = [[CLEAR] * size for _ in range(size)]
57
+ for y in range(size):
58
+ for x in range(size):
59
+ # Rounded-rect clip: inside unless outside a corner circle.
60
+ cx = radius if x < radius else (size - 1 - radius if x >= size - radius else x)
61
+ cy = radius if y < radius else (size - 1 - radius if y >= size - radius else y)
62
+ if (x - cx) ** 2 + (y - cy) ** 2 > radius * radius and (
63
+ (x < radius or x >= size - radius) and (y < radius or y >= size - radius)
64
+ ):
65
+ continue
66
+ cell = ART[y * GRID // size][x * GRID // size]
67
+ grid[y][x] = INK.get(cell, TILE_BG)
68
+ return grid
69
+
70
+
71
+ def render_logo_png(size: int = 256) -> bytes:
72
+ """Encode the mark at ``size`` px as an RGBA PNG (the canonical asset)."""
73
+ grid = logo_pixels(size)
74
+ h = w = size
75
+
76
+ def chunk(tag: bytes, data: bytes) -> bytes:
77
+ return (
78
+ struct.pack(">I", len(data)) + tag + data
79
+ + struct.pack(">I", zlib.crc32(tag + data))
80
+ )
81
+
82
+ raw = b"".join(
83
+ b"\x00" + b"".join(bytes(px) for px in row) for row in grid
84
+ )
85
+ return (
86
+ b"\x89PNG\r\n\x1a\n"
87
+ + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
88
+ + chunk(b"IDAT", zlib.compress(raw, 9))
89
+ + chunk(b"IEND", b"")
90
+ )
seedcode/codemode.py ADDED
@@ -0,0 +1,363 @@
1
+ """.seedcode — workspace-aware project memory for Code Mode (v6.2.0).
2
+
3
+ When Code Mode is enabled, the project root gains a ``.seedcode/`` directory::
4
+
5
+ .seedcode/
6
+ ├── memory/ durable project knowledge (architecture, decisions…)
7
+ ├── index/ per-file summaries + a file map (incremental, hashed)
8
+ ├── context/ reusable project context (conventions, snippets)
9
+ ├── sessions/ compact per-session summaries (never raw transcripts)
10
+ └── config.json safe project configuration
11
+
12
+ Design rules:
13
+
14
+ * **Incremental indexing.** Every indexed file is hashed; on the next enable
15
+ only files whose hash changed are re-summarised (``refresh_index``), so a
16
+ large project costs one ``stat`` per file instead of a full re-read.
17
+ * **Secrets never land here.** Everything written passes the same
18
+ secret-key filter as :mod:`seedcode.memory.store` — API keys, tokens, and
19
+ passwords are rejected at write time.
20
+ * **Not source code.** ``.seedcode`` is excluded from workspace search/index
21
+ (``tools.search._INDEX_SKIP`` / ``filesystem._INDEX_SKIP``) and from the
22
+ agent's project tree, and the repo-level ``.gitignore`` already lists it.
23
+ * **Session notes are compact.** ``save_session_summary`` stores one small
24
+ JSON per session (goal, changes, verification) — never raw transcripts.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import re
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ # Keys that must never be persisted into .seedcode (mirrors memory.store).
37
+ _SECRET_KEY_RE = re.compile(
38
+ r"(password|passwd|pwd|secret|token|api[_-]?key|apikey|credential|"
39
+ r"auth|cookie|session[_-]?id|private[_-]?key|access[_-]?key)",
40
+ re.IGNORECASE,
41
+ )
42
+
43
+ SEEDCODE_DIRNAME = ".seedcode"
44
+
45
+ # Files whose content is worth summarising into the index.
46
+ _CODE_SUFFIXES = {
47
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs", ".go", ".rs", ".java",
48
+ ".kt", ".c", ".h", ".cpp", ".hpp", ".cs", ".rb", ".php", ".swift", ".m",
49
+ ".md", ".toml", ".yaml", ".yml", ".json", ".sql", ".sh", ".ps1", ".bat",
50
+ }
51
+ _MAX_INDEX_FILE_BYTES = 256 * 1024
52
+ _MAX_SUMMARY_LINES = 40
53
+ _MAX_INDEX_FILES = 2_000
54
+
55
+ DEFAULT_CONFIG: dict[str, Any] = {
56
+ "version": 1,
57
+ "codemode": True,
58
+ "notes": "Safe project configuration only — never store secrets here.",
59
+ }
60
+
61
+
62
+ # --- secret guard -------------------------------------------------------------
63
+ def assert_no_secrets(data: Any, path: str = "") -> None:
64
+ """Reject nested secret-looking keys before anything touches disk."""
65
+ if isinstance(data, dict):
66
+ for key, value in data.items():
67
+ here = f"{path}.{key}" if path else str(key)
68
+ if _SECRET_KEY_RE.search(str(key)):
69
+ raise ValueError(
70
+ f"Refusing to store a secret-looking field ('{here}') in "
71
+ ".seedcode. Credentials belong in a credential manager."
72
+ )
73
+ assert_no_secrets(value, here)
74
+ elif isinstance(data, list):
75
+ for i, item in enumerate(data):
76
+ assert_no_secrets(item, f"{path}[{i}]")
77
+
78
+
79
+ def _write_json(path: Path, payload: dict[str, Any]) -> bool:
80
+ try:
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ tmp = path.with_suffix(path.suffix + ".tmp")
83
+ tmp.write_text(
84
+ json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8"
85
+ )
86
+ tmp.replace(path)
87
+ return True
88
+ except (OSError, ValueError):
89
+ return False
90
+
91
+
92
+ def _read_json(path: Path) -> dict[str, Any] | None:
93
+ try:
94
+ data = json.loads(path.read_text(encoding="utf-8"))
95
+ except (OSError, ValueError):
96
+ return None
97
+ return data if isinstance(data, dict) else None
98
+
99
+
100
+ def _write_text(path: Path, text: str) -> bool:
101
+ try:
102
+ path.parent.mkdir(parents=True, exist_ok=True)
103
+ path.write_text(text, encoding="utf-8")
104
+ return True
105
+ except OSError:
106
+ return False
107
+
108
+
109
+ # --- the workspace store ---------------------------------------------------------
110
+ class SeedcodeStore:
111
+ """Read/write access to one project's ``.seedcode/`` directory."""
112
+
113
+ def __init__(self, workspace: Path) -> None:
114
+ self.workspace = workspace
115
+ self.root = workspace / SEEDCODE_DIRNAME
116
+
117
+ # --- structure -----------------------------------------------------------
118
+ def ensure(self) -> bool:
119
+ """Create the full directory skeleton when missing; True when ready."""
120
+ try:
121
+ for sub in ("memory", "index", "context", "sessions"):
122
+ (self.root / sub).mkdir(parents=True, exist_ok=True)
123
+ config_path = self.root / "config.json"
124
+ if not config_path.exists():
125
+ _write_json(config_path, DEFAULT_CONFIG)
126
+ return self.root.is_dir()
127
+ except OSError:
128
+ return False
129
+
130
+ @property
131
+ def exists(self) -> bool:
132
+ return self.root.is_dir()
133
+
134
+ # --- paths -----------------------------------------------------------------
135
+ def memory_path(self, name: str) -> Path:
136
+ safe = re.sub(r"[^a-zA-Z0-9._-]", "_", name)[:60] or "note"
137
+ return self.root / "memory" / f"{safe}.md"
138
+
139
+ # --- memory ------------------------------------------------------------------
140
+ def save_memory(self, name: str, content: str) -> bool:
141
+ """Persist one markdown memory note (architecture, decisions, ...)."""
142
+ self.ensure()
143
+ return _write_text(self.memory_path(name), content)
144
+
145
+ def load_memory(self, name: str) -> str:
146
+ try:
147
+ return self.memory_path(name).read_text(encoding="utf-8")
148
+ except OSError:
149
+ return ""
150
+
151
+ def list_memories(self) -> list[str]:
152
+ try:
153
+ return sorted(p.stem for p in (self.root / "memory").glob("*.md"))
154
+ except OSError:
155
+ return []
156
+
157
+ # --- context ---------------------------------------------------------------
158
+ def save_context(self, name: str, content: str) -> bool:
159
+ self.ensure()
160
+ return _write_text(self.root / "context" / f"{name}.md", content)
161
+
162
+ def load_context(self, name: str) -> str:
163
+ try:
164
+ return (self.root / "context" / f"{name}.md").read_text(encoding="utf-8")
165
+ except OSError:
166
+ return ""
167
+
168
+ # --- sessions ---------------------------------------------------------------
169
+ def save_session_summary(self, summary: dict[str, Any]) -> bool:
170
+ """One compact JSON per session (never a raw transcript)."""
171
+ self.ensure()
172
+ assert_no_secrets(summary)
173
+ stamp = time.strftime("%Y%m%d-%H%M%S")
174
+ return _write_json(self.root / "sessions" / f"session-{stamp}.json", summary)
175
+
176
+ def latest_session_summaries(self, limit: int = 5) -> list[dict[str, Any]]:
177
+ try:
178
+ paths = sorted((self.root / "sessions").glob("session-*.json"), reverse=True)
179
+ except OSError:
180
+ return []
181
+ out: list[dict[str, Any]] = []
182
+ for path in paths[: max(1, limit)]:
183
+ data = _read_json(path)
184
+ if data:
185
+ out.append(data)
186
+ return out
187
+
188
+ # --- config -----------------------------------------------------------------
189
+ def load_config(self) -> dict[str, Any]:
190
+ return _read_json(self.root / "config.json") or dict(DEFAULT_CONFIG)
191
+
192
+ def save_config(self, config: dict[str, Any]) -> bool:
193
+ assert_no_secrets(config)
194
+ return _write_json(self.root / "config.json", config)
195
+
196
+ # --- index --------------------------------------------------------------------
197
+ def _map_path(self) -> Path:
198
+ return self.root / "index" / "files.json"
199
+
200
+ def load_file_map(self) -> dict[str, dict[str, Any]]:
201
+ data = _read_json(self._map_path())
202
+ files = data.get("files") if data else None
203
+ return files if isinstance(files, dict) else {}
204
+
205
+ def _save_file_map(self, files: dict[str, dict[str, Any]]) -> bool:
206
+ return _write_json(self._map_path(), {"files": files})
207
+
208
+ @staticmethod
209
+ def _hash_text(text: str) -> str:
210
+ return hashlib.sha1(text.encode("utf-8", "replace")).hexdigest()
211
+
212
+ def index_file(self, rel: str, text: str) -> dict[str, Any]:
213
+ """Summarise one file's text (used by tests; refresh_index calls _summarize)."""
214
+ return self._summarize(text)
215
+
216
+ def _summarize(self, text: str) -> dict[str, Any]:
217
+ lines = text.splitlines()
218
+ header = lines[0].strip() if lines else ""
219
+ # Collect low-cost orientation signals.
220
+ symbols: list[str] = []
221
+ for line in lines:
222
+ stripped = line.strip()
223
+ if stripped.startswith(("def ", "class ", "function ", "fn ", "pub fn ")):
224
+ # Store the bare identifier ('login', not 'def login').
225
+ head = stripped.split("(")[0].split(":")[0].strip()
226
+ for keyword in ("def ", "class ", "function ", "fn ", "pub fn "):
227
+ if head.startswith(keyword):
228
+ head = head[len(keyword):].strip()
229
+ break
230
+ if head:
231
+ symbols.append(head[:60])
232
+ if len(symbols) >= 12:
233
+ break
234
+ return {
235
+ "lines": len(lines),
236
+ "header": header[:120],
237
+ "symbols": symbols,
238
+ }
239
+
240
+ def refresh_index(self, changed_limit: int = 200) -> dict[str, int]:
241
+ """Incremental index refresh over workspace text files.
242
+
243
+ Returns ``{"indexed": n, "unchanged": m}``. Only files whose content
244
+ hash differs from the stored map are re-read and re-summarised; a
245
+ fresh index walks the tree once, afterwards each refresh costs one
246
+ ``stat`` per file until something changes.
247
+ """
248
+ self.ensure()
249
+ old_map = self.load_file_map()
250
+ new_map: dict[str, dict[str, Any]] = {}
251
+ indexed = unchanged = 0
252
+ try:
253
+ entries = sorted(self.workspace.rglob("*"))
254
+ except OSError:
255
+ entries = []
256
+ for path in entries:
257
+ if len(new_map) >= _MAX_INDEX_FILES:
258
+ break
259
+ name = path.name
260
+ if not path.is_file():
261
+ continue
262
+ if (
263
+ SEEDCODE_DIRNAME in path.parts
264
+ or name.startswith(".")
265
+ or name in _INDEX_SKIP_NAMES
266
+ or _INDEX_SKIP_NAMES.intersection(path.parts)
267
+ ):
268
+ continue
269
+ if path.suffix.lower() not in _CODE_SUFFIXES:
270
+ continue
271
+ try:
272
+ size = path.stat().st_size
273
+ except OSError:
274
+ continue
275
+ if size > _MAX_INDEX_FILE_BYTES:
276
+ continue
277
+ rel = path.relative_to(self.workspace).as_posix()
278
+ new_map[rel] = {"size": size}
279
+ previous = old_map.get(rel)
280
+ if (
281
+ previous
282
+ and previous.get("size") == size
283
+ and (self.root / "index" / f"{_entry_hash(rel)}.json").exists()
284
+ ):
285
+ new_map[rel].update(previous)
286
+ unchanged += 1
287
+ continue
288
+ try:
289
+ text = path.read_text(encoding="utf-8", errors="replace")
290
+ except OSError:
291
+ continue
292
+ summary = self._summarize(text)
293
+ digest = self._hash_text(text)
294
+ entry = {**summary, "hash": digest, "size": size}
295
+ if indexed < changed_limit:
296
+ _write_json(self.root / "index" / f"{_entry_hash(rel)}.json", entry)
297
+ indexed += 1
298
+ new_map[rel].update(entry)
299
+ self._save_file_map(new_map)
300
+ return {"indexed": indexed, "unchanged": unchanged}
301
+
302
+ def query_index(self, needle: str, limit: int = 12) -> list[dict[str, Any]]:
303
+ """Search the index map (paths, headers, symbols) without reading files."""
304
+ low = needle.strip().lower()
305
+ if not low:
306
+ return []
307
+ hits: list[dict[str, Any]] = []
308
+ for rel, entry in self.load_file_map().items():
309
+ hay = " ".join([
310
+ rel,
311
+ str(entry.get("header", "")),
312
+ " ".join(str(s) for s in entry.get("symbols", [])),
313
+ ]).lower()
314
+ if low in hay:
315
+ hits.append({"path": rel, **{k: entry.get(k) for k in ("header", "symbols", "lines")}})
316
+ if len(hits) >= limit:
317
+ break
318
+ return hits
319
+
320
+
321
+ def _entry_hash(rel: str) -> str:
322
+ return hashlib.sha1(rel.encode("utf-8", "replace")).hexdigest()[:16]
323
+
324
+
325
+ # Directory names that never belong in a .seedcode index.
326
+ _INDEX_SKIP_NAMES = {
327
+ "__pycache__", "node_modules", ".venv", "venv", "dist", "build",
328
+ ".git", ".pytest_cache", ".mypy_cache", ".ruff_cache", ".idea", ".vscode",
329
+ "target", "vendor", ".next", ".cache",
330
+ }
331
+
332
+
333
+ def workspace_iter(root: Path):
334
+ """Workspace files for indexing/search, skipping .seedcode and caches."""
335
+ stack = [root]
336
+ while stack:
337
+ current = stack.pop()
338
+ try:
339
+ entries = sorted(current.iterdir(), key=lambda p: p.name.lower())
340
+ except OSError:
341
+ continue
342
+ for entry in entries:
343
+ if entry.name in _INDEX_SKIP_NAMES or entry.name.startswith("."):
344
+ continue
345
+ if entry.is_dir():
346
+ stack.append(entry)
347
+ elif entry.is_file():
348
+ yield entry
349
+
350
+
351
+ # --- module-level helpers ---------------------------------------------------------
352
+ def store_for(workspace: Path) -> SeedcodeStore:
353
+ return SeedcodeStore(workspace)
354
+
355
+
356
+ __all__ = [
357
+ "SEEDCODE_DIRNAME",
358
+ "DEFAULT_CONFIG",
359
+ "SeedcodeStore",
360
+ "assert_no_secrets",
361
+ "store_for",
362
+ "workspace_iter",
363
+ ]