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 +1 -1
- seedcode/_default_key_template.py +12 -0
- seedcode/app.py +42 -4
- seedcode/apps/discovery.py +37 -2
- seedcode/assets/logo.png +0 -0
- seedcode/branding.py +90 -0
- seedcode/codemode.py +363 -0
- seedcode/codemode_state.py +89 -0
- seedcode/commands/__init__.py +1 -1
- seedcode/commands/codemode.py +102 -0
- seedcode/computer/controller.py +32 -3
- seedcode/computer/engine.py +3 -0
- seedcode/computer/keyboard.py +9 -1
- seedcode/computer/mouse.py +15 -6
- seedcode/computer/state.py +23 -1
- seedcode/config/manager.py +26 -0
- seedcode/core/agent.py +128 -12
- seedcode/core/chat.py +3 -1
- seedcode/core/http.py +62 -0
- seedcode/core/providers/aerolink.py +4 -3
- seedcode/core/providers/freemodel.py +6 -5
- seedcode/core/providers/ollama.py +5 -4
- seedcode/core/providers/openrouter.py +3 -2
- seedcode/default_api.py +105 -0
- seedcode/tools/base.py +16 -1
- seedcode/tools/desktop.py +24 -0
- seedcode/tools/filesystem.py +3 -1
- seedcode/ui/__init__.py +14 -9
- seedcode/ui/dashboard.py +310 -149
- seedcode/ui/logo.py +134 -0
- seedcode/ui/renderer.py +27 -1
- seedcode_cli-6.2.0.dist-info/METADATA +401 -0
- {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.0.dist-info}/RECORD +36 -27
- {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.0.dist-info}/WHEEL +1 -1
- seedcode_cli-6.1.5.dist-info/METADATA +0 -368
- {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.0.dist-info}/entry_points.txt +0 -0
- {seedcode_cli-6.1.5.dist-info → seedcode_cli-6.2.0.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
-
|
|
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.")
|
seedcode/apps/discovery.py
CHANGED
|
@@ -19,7 +19,8 @@ from __future__ import annotations
|
|
|
19
19
|
|
|
20
20
|
import os
|
|
21
21
|
import shutil
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
seedcode/assets/logo.png
ADDED
|
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
|
+
]
|