siftcode 0.3.0__py3-none-any.whl → 0.4.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.
- siftcode/__init__.py +3 -1
- siftcode/_version.py +48 -4
- siftcode/cli.py +269 -51
- siftcode/core/__init__.py +1 -1
- siftcode/core/analyzer.py +219 -95
- siftcode/core/cache.py +80 -21
- siftcode/core/config.py +161 -21
- siftcode/core/llm.py +205 -16
- siftcode/core/manual.py +262 -58
- siftcode/core/prompts.py +33 -0
- siftcode/data/hicolor/128x128/apps/siftcode-logo.png +0 -0
- siftcode/data/hicolor/256x256/apps/siftcode-logo.png +0 -0
- siftcode/data/hicolor/48x48/apps/siftcode-logo.png +0 -0
- siftcode/gui/__init__.py +4 -2
- siftcode/gui/analyzer_view.py +12 -0
- siftcode/gui/app.py +54 -59
- siftcode/gui/settings_view.py +59 -1
- {siftcode-0.3.0.dist-info → siftcode-0.4.0.dist-info}/METADATA +191 -38
- siftcode-0.4.0.dist-info/RECORD +34 -0
- {siftcode-0.3.0.dist-info → siftcode-0.4.0.dist-info}/WHEEL +1 -1
- siftcode-0.3.0.dist-info/RECORD +0 -31
- {siftcode-0.3.0.dist-info → siftcode-0.4.0.dist-info}/entry_points.txt +0 -0
- {siftcode-0.3.0.dist-info → siftcode-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {siftcode-0.3.0.dist-info → siftcode-0.4.0.dist-info}/top_level.txt +0 -0
siftcode/__init__.py
CHANGED
siftcode/_version.py
CHANGED
|
@@ -7,9 +7,40 @@ version, name, author, license, and other identifying information.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"__version_info__",
|
|
16
|
+
"__release_date__",
|
|
17
|
+
"__status__",
|
|
18
|
+
"APP_ID",
|
|
19
|
+
"APP_NAME",
|
|
20
|
+
"APP_SLUG",
|
|
21
|
+
"APP_TAGLINE",
|
|
22
|
+
"APP_SUMMARY",
|
|
23
|
+
"AUTHOR_NAME",
|
|
24
|
+
"AUTHOR_EMAIL",
|
|
25
|
+
"COPYRIGHT",
|
|
26
|
+
"COPYRIGHT_YEAR",
|
|
27
|
+
"LICENSE_NAME",
|
|
28
|
+
"LICENSE_SPDX",
|
|
29
|
+
"LICENSE_URL",
|
|
30
|
+
"HOMEPAGE_URL",
|
|
31
|
+
"REPOSITORY_URL",
|
|
32
|
+
"ISSUES_URL",
|
|
33
|
+
"CHANGELOG_URL",
|
|
34
|
+
"DESCRIPTION",
|
|
35
|
+
"version_string",
|
|
36
|
+
"short_version",
|
|
37
|
+
"about_data",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__version__ = "0.4.0"
|
|
11
42
|
__version_info__ = tuple(int(p) for p in __version__.split(".") if p.isdigit())
|
|
12
|
-
__release_date__ = "2026-
|
|
43
|
+
__release_date__ = "2026-11-12"
|
|
13
44
|
__status__ = "beta"
|
|
14
45
|
|
|
15
46
|
APP_ID = "io.codeberg.lahtis.Siftcode"
|
|
@@ -39,19 +70,32 @@ DESCRIPTION = (
|
|
|
39
70
|
"Everything runs on your own hardware — no code leaves your machine."
|
|
40
71
|
)
|
|
41
72
|
|
|
73
|
+
|
|
42
74
|
def version_string() -> str:
|
|
75
|
+
"""Return a human-readable version string, e.g. 'Siftcode 0.3.0 (beta)'."""
|
|
43
76
|
return f"{APP_NAME} {__version__} ({__status__})"
|
|
44
77
|
|
|
78
|
+
|
|
45
79
|
def short_version() -> str:
|
|
80
|
+
"""Return just the version number, e.g. '0.3.0'."""
|
|
46
81
|
return __version__
|
|
47
82
|
|
|
48
|
-
|
|
83
|
+
|
|
84
|
+
def about_data() -> dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Return a dictionary of metadata suitable for Gtk.AboutDialog.
|
|
87
|
+
|
|
88
|
+
The 'license_type' value is a Gtk.License enum name; 'gpl-3-0-later'
|
|
89
|
+
matches LICENSE_SPDX = 'GPL-3.0-or-later'. Using 'gpl-3-0' here would
|
|
90
|
+
incorrectly claim GPL-3.0-only, which is stricter than the project's
|
|
91
|
+
actual license.
|
|
92
|
+
"""
|
|
49
93
|
return {
|
|
50
94
|
"application_name": APP_NAME,
|
|
51
95
|
"application_icon": APP_ID,
|
|
52
96
|
"version": __version__,
|
|
53
97
|
"developer_name": AUTHOR_NAME,
|
|
54
|
-
"license_type": "gpl-3-0",
|
|
98
|
+
"license_type": "gpl-3-0-later",
|
|
55
99
|
"comments": DESCRIPTION,
|
|
56
100
|
"website": HOMEPAGE_URL,
|
|
57
101
|
"issue_url": ISSUES_URL,
|
siftcode/cli.py
CHANGED
|
@@ -6,6 +6,7 @@ Provides a thin layer over the core modules:
|
|
|
6
6
|
- cache: analysis cache management
|
|
7
7
|
- llm: Ollama client
|
|
8
8
|
- analyzer: project analysis engine
|
|
9
|
+
- manual: advanced per-file specification generator
|
|
9
10
|
|
|
10
11
|
No GTK dependencies.
|
|
11
12
|
"""
|
|
@@ -14,6 +15,7 @@ from __future__ import annotations
|
|
|
14
15
|
|
|
15
16
|
import argparse
|
|
16
17
|
import logging
|
|
18
|
+
import shutil
|
|
17
19
|
import sys
|
|
18
20
|
from datetime import datetime
|
|
19
21
|
from pathlib import Path
|
|
@@ -36,13 +38,17 @@ from siftcode.core.analyzer import ProjectAnalyzer
|
|
|
36
38
|
from siftcode.core.cache import AnalysisCache
|
|
37
39
|
from siftcode.core.config import (
|
|
38
40
|
PROJECTS_DIR,
|
|
41
|
+
ModelConfig,
|
|
39
42
|
ProjectConfig,
|
|
40
43
|
delete_project,
|
|
41
44
|
list_projects,
|
|
42
45
|
load_project,
|
|
43
46
|
save_project,
|
|
44
47
|
)
|
|
45
|
-
from siftcode.core.llm import
|
|
48
|
+
from siftcode.core.llm import (
|
|
49
|
+
LLMError,
|
|
50
|
+
OllamaClient,
|
|
51
|
+
)
|
|
46
52
|
from siftcode.core.manual import SpecGenerator
|
|
47
53
|
|
|
48
54
|
|
|
@@ -86,34 +92,91 @@ def _resolve_project(name_or_path: str) -> ProjectConfig:
|
|
|
86
92
|
f"hint: '{name_or_path}' is not a directory and no saved project "
|
|
87
93
|
f"matches. Run '{APP_SLUG} list' to see saved projects."
|
|
88
94
|
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def _check_ollama(client: OllamaClient) -> None:
|
|
92
|
-
if not client.is_available():
|
|
95
|
+
except ValueError as e:
|
|
96
|
+
# load_project now raises ValueError for malformed TOML files.
|
|
93
97
|
raise SystemExit(
|
|
94
|
-
f"error:
|
|
95
|
-
f"hint:
|
|
96
|
-
f" Try: ollama serve"
|
|
98
|
+
f"error: saved project '{name_or_path}' is invalid: {e}\n"
|
|
99
|
+
f"hint: fix or delete the config file under {PROJECTS_DIR}"
|
|
97
100
|
)
|
|
98
101
|
|
|
99
102
|
|
|
100
|
-
def
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
103
|
+
def _parse_model_stages(specs: list[str]) -> dict[str, str]:
|
|
104
|
+
"""
|
|
105
|
+
Parse --model-stage STAGE=MODEL arguments into a dict.
|
|
106
|
+
|
|
107
|
+
Raises ValueError with a user-friendly message if any spec is
|
|
108
|
+
malformed. Used by cmd_analyze before config.validate() so the
|
|
109
|
+
error is reported as a CLI error (exit 2) rather than a config
|
|
110
|
+
validation error.
|
|
111
|
+
"""
|
|
112
|
+
stages: dict[str, str] = {}
|
|
113
|
+
for spec in specs:
|
|
114
|
+
if "=" not in spec:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"--model-stage requires STAGE=MODEL, got {spec!r}.\n"
|
|
117
|
+
f"Valid stages: {', '.join(ModelConfig.VALID_STAGES)}"
|
|
118
|
+
)
|
|
119
|
+
stage, _, model_name = spec.partition("=")
|
|
120
|
+
stage = stage.strip()
|
|
121
|
+
model_name = model_name.strip()
|
|
122
|
+
if not stage or not model_name:
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"--model-stage requires STAGE=MODEL, got {spec!r}.\n"
|
|
125
|
+
f"Valid stages: {', '.join(ModelConfig.VALID_STAGES)}"
|
|
126
|
+
)
|
|
127
|
+
if stage not in ModelConfig.VALID_STAGES:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"unknown stage '{stage}' in --model-stage.\n"
|
|
130
|
+
f"Valid stages: {', '.join(ModelConfig.VALID_STAGES)}"
|
|
131
|
+
)
|
|
132
|
+
stages[stage] = model_name
|
|
133
|
+
return stages
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_ollama(client: OllamaClient, config: ProjectConfig) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Verify Ollama is reachable AND all configured models are installed.
|
|
139
|
+
|
|
140
|
+
The actual diagnosis (DNS vs refused vs timeout) is done by
|
|
141
|
+
OllamaClient.check_connection() in llm.py, so both the CLI and
|
|
142
|
+
the GUI get the same user-friendly messages from one place.
|
|
143
|
+
|
|
144
|
+
All models referenced by config.model.stages are checked here
|
|
145
|
+
so a missing model fails at startup, not 20 minutes into a run
|
|
146
|
+
when the guide stage tries to load it.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
client.check_connection()
|
|
150
|
+
except LLMError as e:
|
|
151
|
+
raise SystemExit(f"error: {e}")
|
|
152
|
+
|
|
153
|
+
# Collect every model the run will use: the default plus any
|
|
154
|
+
# stage-specific overrides.
|
|
155
|
+
models = {config.model.name} | set(config.model.stages.values())
|
|
156
|
+
for model in sorted(models):
|
|
157
|
+
if not client.has_model(model):
|
|
158
|
+
raise SystemExit(
|
|
159
|
+
f"error: model '{model}' is not installed in Ollama\n"
|
|
160
|
+
f"hint: install it with: ollama pull {model}"
|
|
161
|
+
)
|
|
106
162
|
|
|
107
163
|
|
|
108
164
|
def _print_progress(progress) -> None:
|
|
109
165
|
bar_width = 30
|
|
110
166
|
filled = int(bar_width * progress.fraction)
|
|
111
167
|
bar = "█" * filled + "░" * (bar_width - filled)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
168
|
+
msg = f" [{bar}] {progress.fraction * 100:5.1f}% {progress.message}"
|
|
169
|
+
|
|
170
|
+
cols = shutil.get_terminal_size().columns
|
|
171
|
+
if len(msg) > cols - 1:
|
|
172
|
+
msg = msg[: cols - 4] + "..."
|
|
173
|
+
|
|
174
|
+
# Progress bar goes to stderr: visible on screen, but never mixed
|
|
175
|
+
# into a piped/logged stdout stream. \033[K clears the previous line.
|
|
176
|
+
print(f"\r\033[K{msg}", end="", file=sys.stderr, flush=True)
|
|
177
|
+
|
|
178
|
+
if progress.fraction >= 1.0:
|
|
179
|
+
print(file=sys.stderr)
|
|
117
180
|
|
|
118
181
|
|
|
119
182
|
def _human_size(num_bytes: int) -> str:
|
|
@@ -124,6 +187,43 @@ def _human_size(num_bytes: int) -> str:
|
|
|
124
187
|
return f"{num_bytes:.1f} TB"
|
|
125
188
|
|
|
126
189
|
|
|
190
|
+
def _safe_stem(name: str) -> str:
|
|
191
|
+
"""
|
|
192
|
+
Convert a project name into a filesystem-safe stem.
|
|
193
|
+
|
|
194
|
+
Replaces path separators, spaces, and other problematic characters
|
|
195
|
+
so that a project name like "my/project" doesn't create subdirectories
|
|
196
|
+
or fail on Windows.
|
|
197
|
+
"""
|
|
198
|
+
safe = "".join(
|
|
199
|
+
c if c.isalnum() or c in ("-", "_", ".") else "_"
|
|
200
|
+
for c in name
|
|
201
|
+
).strip("._")
|
|
202
|
+
return safe or "project"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _stamp() -> str:
|
|
206
|
+
"""Single timestamp used for all output files of one run."""
|
|
207
|
+
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _save_last_run(config: ProjectConfig) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Persist last_run after a successful analysis.
|
|
213
|
+
|
|
214
|
+
Failures are logged but never abort the command: the analysis
|
|
215
|
+
results are already on disk and more valuable than the timestamp.
|
|
216
|
+
"""
|
|
217
|
+
config.last_run = datetime.now().isoformat(timespec="seconds")
|
|
218
|
+
try:
|
|
219
|
+
save_project(config)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(
|
|
222
|
+
"Could not update last_run for project '%s': %s",
|
|
223
|
+
config.name, e,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
127
227
|
# ---------------------------------------------------------------------------
|
|
128
228
|
# Commands
|
|
129
229
|
# ---------------------------------------------------------------------------
|
|
@@ -139,79 +239,149 @@ def cmd_analyze(args: argparse.Namespace) -> int:
|
|
|
139
239
|
config.model.name = args.model
|
|
140
240
|
if args.num_ctx:
|
|
141
241
|
config.model.num_ctx = args.num_ctx
|
|
242
|
+
if getattr(args, "spec_num_ctx", None):
|
|
243
|
+
config.model.spec_num_ctx = args.spec_num_ctx
|
|
142
244
|
if args.timeout:
|
|
143
245
|
config.model.timeout = args.timeout
|
|
144
246
|
|
|
247
|
+
# Parse --model-stage arguments into config.model.stages. Done
|
|
248
|
+
# before validate() so malformed specs are reported as CLI errors
|
|
249
|
+
# (exit 2) rather than config validation errors.
|
|
250
|
+
if getattr(args, "model_stage", None):
|
|
251
|
+
try:
|
|
252
|
+
config.model.stages.update(
|
|
253
|
+
_parse_model_stages(args.model_stage)
|
|
254
|
+
)
|
|
255
|
+
except ValueError as e:
|
|
256
|
+
print(f"error: {e}", file=sys.stderr)
|
|
257
|
+
return 2
|
|
258
|
+
|
|
259
|
+
# Validate BEFORE persisting, so we never save a broken config.
|
|
260
|
+
config.validate()
|
|
261
|
+
|
|
262
|
+
# Persist (only if the project already exists as a saved project).
|
|
263
|
+
# A malformed existing TOML is overwritten with the validated config.
|
|
145
264
|
try:
|
|
146
265
|
load_project(config.name)
|
|
147
266
|
save_project(config)
|
|
148
|
-
logger.info(
|
|
267
|
+
logger.info("Updated saved project '%s'", config.name)
|
|
149
268
|
except FileNotFoundError:
|
|
150
269
|
pass
|
|
151
|
-
|
|
152
|
-
|
|
270
|
+
except ValueError as e:
|
|
271
|
+
logger.warning(
|
|
272
|
+
"Existing project config is invalid and will be overwritten: %s", e,
|
|
273
|
+
)
|
|
274
|
+
save_project(config)
|
|
153
275
|
|
|
154
276
|
output_dir = Path(args.output).expanduser() if args.output else Path.cwd()
|
|
155
277
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
156
278
|
|
|
157
279
|
client = OllamaClient(config.model)
|
|
158
|
-
_check_ollama(client)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
logger.info(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
280
|
+
_check_ollama(client, config)
|
|
281
|
+
|
|
282
|
+
logger.info("Analyzing: %s", config.path)
|
|
283
|
+
logger.info("Model: %s", config.model.name)
|
|
284
|
+
if config.model.stages:
|
|
285
|
+
for stage, model_name in sorted(config.model.stages.items()):
|
|
286
|
+
logger.info(" %-12s %s", f"{stage}:", model_name)
|
|
287
|
+
logger.info("Context: %d tokens", config.model.num_ctx)
|
|
288
|
+
if args.advanced:
|
|
289
|
+
logger.info("Spec ctx: %d tokens", config.model.spec_num_ctx)
|
|
290
|
+
|
|
291
|
+
# One timestamp for the whole run: findings/guide/specs all share it.
|
|
292
|
+
run_stamp = _stamp()
|
|
293
|
+
project_stem = _safe_stem(config.name)
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------
|
|
296
|
+
# Advanced per-file specification mode
|
|
297
|
+
# ------------------------------------------------------------------
|
|
298
|
+
if args.advanced:
|
|
299
|
+
analyzer = ProjectAnalyzer(config, client)
|
|
300
|
+
files_to_analyze = analyzer.collect_files()
|
|
170
301
|
|
|
171
302
|
if not files_to_analyze:
|
|
172
|
-
print("No
|
|
303
|
+
print("No analyzable files found for advanced specification generation.")
|
|
173
304
|
return 0
|
|
174
305
|
|
|
175
306
|
spec_gen = SpecGenerator(config=config, client=client)
|
|
176
|
-
specs_output_dir = output_dir / f"specs_{
|
|
307
|
+
specs_output_dir = output_dir / f"specs_{project_stem}_{run_stamp}"
|
|
177
308
|
specs_output_dir.mkdir(parents=True, exist_ok=True)
|
|
178
309
|
|
|
179
|
-
print(f"Running advanced per-file technical specification
|
|
310
|
+
print(f"Running advanced per-file technical specification "
|
|
311
|
+
f"for {len(files_to_analyze)} files...")
|
|
180
312
|
|
|
181
313
|
def progress_cb(current: int, total: int, path: Path) -> None:
|
|
182
|
-
|
|
314
|
+
try:
|
|
315
|
+
rel = path.relative_to(config.path).as_posix()
|
|
316
|
+
except ValueError:
|
|
317
|
+
rel = path.name
|
|
183
318
|
if len(rel) > 35:
|
|
184
319
|
rel = "..." + rel[-32:]
|
|
185
|
-
fraction = current / total
|
|
320
|
+
fraction = current / total if total else 1.0
|
|
186
321
|
bar_width = 20
|
|
187
322
|
filled = int(bar_width * fraction)
|
|
188
323
|
bar = "█" * filled + "░" * (bar_width - filled)
|
|
189
324
|
msg = f" [{bar}] {fraction * 100:5.1f}% ({current}/{total}) {rel}"
|
|
190
|
-
|
|
191
|
-
|
|
325
|
+
|
|
326
|
+
cols = shutil.get_terminal_size().columns
|
|
327
|
+
if len(msg) > cols - 1:
|
|
328
|
+
msg = msg[: cols - 4] + "..."
|
|
329
|
+
|
|
330
|
+
print(f"\r\033[K{msg}", end="", file=sys.stderr, flush=True)
|
|
331
|
+
if fraction >= 1.0:
|
|
332
|
+
print(file=sys.stderr)
|
|
192
333
|
|
|
193
334
|
try:
|
|
194
|
-
specs_dict = spec_gen.generate_project_specs(
|
|
335
|
+
specs_dict = spec_gen.generate_project_specs(
|
|
336
|
+
files_to_analyze, progress_callback=progress_cb,
|
|
337
|
+
)
|
|
195
338
|
except KeyboardInterrupt:
|
|
196
339
|
print("\n\ninterrupted by user", file=sys.stderr)
|
|
197
340
|
return 130
|
|
341
|
+
except LLMError as e:
|
|
342
|
+
print(f"\nerror: {e}", file=sys.stderr)
|
|
343
|
+
return 1
|
|
198
344
|
except Exception as e:
|
|
199
345
|
print(f"\nerror: {e}", file=sys.stderr)
|
|
200
346
|
return 1
|
|
201
|
-
|
|
202
|
-
#
|
|
347
|
+
|
|
348
|
+
# Write specs to disk. generate_project_specs already returns
|
|
349
|
+
# only successfully processed files, so specs_dict may be
|
|
350
|
+
# smaller than files_to_analyze. Count the difference and
|
|
351
|
+
# surface it to the user.
|
|
203
352
|
for rel_path, markdown_content in specs_dict.items():
|
|
353
|
+
# Guard against rel_path containing '..' segments, which
|
|
354
|
+
# would let the write escape specs_output_dir.
|
|
355
|
+
safe_rel = Path(rel_path)
|
|
356
|
+
if safe_rel.is_absolute() or ".." in safe_rel.parts:
|
|
357
|
+
logger.warning(
|
|
358
|
+
"Skipping unsafe output path: %s", rel_path,
|
|
359
|
+
)
|
|
360
|
+
continue
|
|
204
361
|
target_file = specs_output_dir / f"{rel_path}.md"
|
|
205
362
|
target_file.parent.mkdir(parents=True, exist_ok=True)
|
|
206
363
|
target_file.write_text(markdown_content, encoding="utf-8")
|
|
207
364
|
|
|
365
|
+
_save_last_run(config)
|
|
366
|
+
|
|
367
|
+
expected = len(files_to_analyze)
|
|
368
|
+
written = len(specs_dict)
|
|
369
|
+
missing = expected - written
|
|
370
|
+
|
|
208
371
|
print()
|
|
209
|
-
print(
|
|
372
|
+
print("✓ Advanced specification generation complete")
|
|
210
373
|
print(f" Output directory: {specs_output_dir}")
|
|
211
|
-
print(f" Files processed: {
|
|
374
|
+
print(f" Files processed: {written}")
|
|
375
|
+
if missing > 0:
|
|
376
|
+
print(
|
|
377
|
+
f"{missing} file(s) failed — see warnings above",
|
|
378
|
+
file=sys.stderr,
|
|
379
|
+
)
|
|
212
380
|
return 0
|
|
213
381
|
|
|
214
|
-
#
|
|
382
|
+
# ------------------------------------------------------------------
|
|
383
|
+
# Normal analysis
|
|
384
|
+
# ------------------------------------------------------------------
|
|
215
385
|
analyzer = ProjectAnalyzer(config, client)
|
|
216
386
|
|
|
217
387
|
try:
|
|
@@ -224,14 +394,16 @@ def cmd_analyze(args: argparse.Namespace) -> int:
|
|
|
224
394
|
print(f"\nerror: {e}", file=sys.stderr)
|
|
225
395
|
return 1
|
|
226
396
|
|
|
227
|
-
findings_path = output_dir / f"findings_{
|
|
228
|
-
guide_path = output_dir / f"guide_{
|
|
397
|
+
findings_path = output_dir / f"findings_{project_stem}_{run_stamp}.json"
|
|
398
|
+
guide_path = output_dir / f"guide_{project_stem}_{run_stamp}.md"
|
|
229
399
|
|
|
230
400
|
analyzer.findings.save(findings_path)
|
|
231
401
|
guide_path.write_text(analyzer.findings.guide, encoding="utf-8")
|
|
232
402
|
|
|
403
|
+
_save_last_run(config)
|
|
404
|
+
|
|
233
405
|
print()
|
|
234
|
-
print(
|
|
406
|
+
print("✓ Analysis complete")
|
|
235
407
|
print(f" Findings: {findings_path}")
|
|
236
408
|
print(f" Guide: {guide_path}")
|
|
237
409
|
print(f" Errors: {len(analyzer.findings.errors)}")
|
|
@@ -264,6 +436,16 @@ def cmd_add(args: argparse.Namespace) -> int:
|
|
|
264
436
|
return 1
|
|
265
437
|
except FileNotFoundError:
|
|
266
438
|
pass
|
|
439
|
+
except ValueError as e:
|
|
440
|
+
# Malformed existing config — overwrite it (the user asked to
|
|
441
|
+
# add this project, and --force semantics apply).
|
|
442
|
+
if not args.force:
|
|
443
|
+
print(
|
|
444
|
+
f"error: existing config for '{name}' is invalid: {e}\n"
|
|
445
|
+
f"hint: use --force to overwrite it",
|
|
446
|
+
file=sys.stderr,
|
|
447
|
+
)
|
|
448
|
+
return 1
|
|
267
449
|
|
|
268
450
|
path = save_project(config)
|
|
269
451
|
print(f"✓ Saved project '{name}'")
|
|
@@ -287,6 +469,11 @@ def cmd_list(args: argparse.Namespace) -> int:
|
|
|
287
469
|
print(f" {marker} {p.name}")
|
|
288
470
|
print(f" path: {p.path}")
|
|
289
471
|
print(f" model: {p.model.name}")
|
|
472
|
+
if p.model.stages:
|
|
473
|
+
stages_str = ", ".join(
|
|
474
|
+
f"{s}={m}" for s, m in sorted(p.model.stages.items())
|
|
475
|
+
)
|
|
476
|
+
print(f" stages: {stages_str}")
|
|
290
477
|
print(f" last run: {last}")
|
|
291
478
|
if p.notes:
|
|
292
479
|
print(f" notes: {p.notes}")
|
|
@@ -300,6 +487,12 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
300
487
|
except FileNotFoundError:
|
|
301
488
|
print(f"error: no saved project named '{args.project}'", file=sys.stderr)
|
|
302
489
|
return 1
|
|
490
|
+
except ValueError as e:
|
|
491
|
+
print(
|
|
492
|
+
f"error: saved project '{args.project}' is invalid: {e}",
|
|
493
|
+
file=sys.stderr,
|
|
494
|
+
)
|
|
495
|
+
return 1
|
|
303
496
|
|
|
304
497
|
print(f"Project: {config.name}")
|
|
305
498
|
print(f" Path: {config.path}")
|
|
@@ -308,8 +501,17 @@ def cmd_show(args: argparse.Namespace) -> int:
|
|
|
308
501
|
print()
|
|
309
502
|
print("Model:")
|
|
310
503
|
print(f" Name: {config.model.name}")
|
|
504
|
+
if config.model.stages:
|
|
505
|
+
print(" Stages:")
|
|
506
|
+
for stage in ModelConfig.VALID_STAGES:
|
|
507
|
+
override = config.model.stages.get(stage, "")
|
|
508
|
+
if override:
|
|
509
|
+
print(f" {stage:12s} {override}")
|
|
510
|
+
else:
|
|
511
|
+
print(f" {stage:12s} (default)")
|
|
311
512
|
print(f" Ollama URL: {config.model.ollama_url}")
|
|
312
513
|
print(f" num_ctx: {config.model.num_ctx}")
|
|
514
|
+
print(f" spec_num_ctx: {config.model.spec_num_ctx}")
|
|
313
515
|
print(f" temperature: {config.model.temperature}")
|
|
314
516
|
print(f" timeout: {config.model.timeout}s")
|
|
315
517
|
print()
|
|
@@ -433,12 +635,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
433
635
|
)
|
|
434
636
|
p_analyze.add_argument(
|
|
435
637
|
"-m", "--model",
|
|
436
|
-
help="Override LLM model (e.g. qwen2.5-coder:14b)"
|
|
638
|
+
help="Override LLM model for all stages (e.g. qwen2.5-coder:14b). "
|
|
639
|
+
"With 24 GB+ VRAM, use --model-stage for per-stage overrides.",
|
|
640
|
+
)
|
|
641
|
+
p_analyze.add_argument(
|
|
642
|
+
"--model-stage",
|
|
643
|
+
action="append",
|
|
644
|
+
default=[],
|
|
645
|
+
metavar="STAGE=MODEL",
|
|
646
|
+
help="Override model for a specific stage (repeatable). "
|
|
647
|
+
"Valid stages: root, file, directories, guide. "
|
|
648
|
+
"Example: --model-stage guide=qwen2.5-coder:32b",
|
|
437
649
|
)
|
|
438
650
|
p_analyze.add_argument(
|
|
439
651
|
"--num-ctx", type=int,
|
|
440
652
|
help="Override context length in tokens",
|
|
441
653
|
)
|
|
654
|
+
p_analyze.add_argument(
|
|
655
|
+
"--spec-num-ctx", type=int,
|
|
656
|
+
help="Context length for --advanced per-file specs "
|
|
657
|
+
"(default: 16384). Lower this on GPUs with limited VRAM "
|
|
658
|
+
"to keep the model fully on the GPU.",
|
|
659
|
+
)
|
|
442
660
|
p_analyze.add_argument(
|
|
443
661
|
"--timeout", type=int,
|
|
444
662
|
help="Override per-request timeout in seconds",
|
siftcode/core/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
"""Siftcode
|
|
1
|
+
"""Core modules for Siftcode: analyzer, cache, config, LLM client, prompts."""
|