refactor-cli 0.2.0__py3-none-any.whl → 0.3.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.
refactor/cli.py CHANGED
@@ -11,18 +11,32 @@ from refactor.organizer import (
11
11
  plan_moves,
12
12
  save_managed_category_dirs,
13
13
  )
14
+ from refactor.plan_cache import load_valid_plan, save_plan
14
15
  from refactor.scanner import scan_directory
15
16
  from refactor.setup import command_setup, ensure_backend_configured, maybe_run_first_time_setup
16
17
  from refactor.shim import build_image_classifier, choose_category_folders
18
+ from refactor.text import (
19
+ TextContext,
20
+ classify_documents,
21
+ managed_text_dirs,
22
+ save_managed_text_dirs,
23
+ scan_documents,
24
+ )
25
+ from refactor.imagga import ImageContext
17
26
  from refactor.tree import print_tree
18
27
  from refactor.validation import validate_path
19
28
 
20
29
  app = typer.Typer(no_args_is_help=True)
21
30
 
31
+ TEXT_PLAN_FILE = ".refactor_text_context.json"
32
+
22
33
 
23
34
  @app.callback()
24
- def bootstrap() -> None:
25
- maybe_run_first_time_setup()
35
+ def bootstrap(ctx: typer.Context) -> None:
36
+ # `refactor setup` is itself the setup command. Running first-time setup in
37
+ # the callback as well caused that command to prompt twice on a new install.
38
+ if ctx.invoked_subcommand != "setup":
39
+ maybe_run_first_time_setup()
26
40
 
27
41
 
28
42
  @app.command()
@@ -33,7 +47,7 @@ def version() -> None:
33
47
 
34
48
  @app.command()
35
49
  def setup() -> None:
36
- """Configure hosted or local multimodal backend."""
50
+ """Configure hosted APIs or a local Ollama multimodal backend."""
37
51
  command_setup()
38
52
 
39
53
 
@@ -87,7 +101,7 @@ def get_image_context(
87
101
  category_root: str = typer.Option(
88
102
  ".",
89
103
  "--category-root",
90
- help="Directory whose existing folders Gemini may reuse",
104
+ help="Directory whose existing folders the configured model may reuse",
91
105
  ),
92
106
  write_context: bool = typer.Option(
93
107
  True,
@@ -121,7 +135,7 @@ def get_image_context(
121
135
 
122
136
  if not images:
123
137
  typer.echo("No image files found in the specified directory.")
124
- raise typer.Exit(code=0)
138
+ return []
125
139
 
126
140
  typer.echo(f"Found {len(images)} image(s) in '{path}'")
127
141
 
@@ -148,9 +162,8 @@ def get_image_context(
148
162
  try:
149
163
  results = choose_category_folders(config, results, Path(category_root))
150
164
  except Exception as exc:
151
- # Folder selection is an essential part of the hosted workflow; do
152
- # not silently fall back to an Imagga top-tag directory.
153
- typer.echo(f"Error: Gemini folder selection failed: {exc}", err=True)
165
+ # Folder selection is essential; do not silently fall back to a top tag.
166
+ typer.echo(f"Error: model folder selection failed: {exc}", err=True)
154
167
  raise typer.Exit(code=1)
155
168
 
156
169
  if verbose:
@@ -183,6 +196,71 @@ def get_image_context(
183
196
  return results
184
197
 
185
198
 
199
+ @app.command(name="refactor-text")
200
+ def refactor_text(
201
+ path: str = typer.Argument(".", help="Directory containing documents to organize"),
202
+ output_dir: str = typer.Option(".", "--output-dir", "-d"),
203
+ dry_run: bool = typer.Option(False, "--dry-run"),
204
+ move: bool = typer.Option(True, "--move/--copy"),
205
+ ) -> list:
206
+ """Use the configured backend to categorize .txt, .md, .pdf, and .docx files."""
207
+ from pathlib import Path
208
+
209
+ try:
210
+ validate_path(path)
211
+ out = Path(output_dir)
212
+ if not out.exists() or not out.is_dir():
213
+ raise ValueError(f"Output directory is not a directory: {output_dir}")
214
+ config = ensure_backend_configured()
215
+ except (ValueError, EnvironmentError) as exc:
216
+ typer.echo(f"Error: {exc}", err=True)
217
+ raise typer.Exit(code=1)
218
+
219
+ patterns = load_ignore_patterns()
220
+ excluded = managed_text_dirs(out) if Path(path).resolve() == out.resolve() else set()
221
+ documents = scan_documents(path, patterns, excluded)
222
+ if not documents:
223
+ typer.echo("No supported document files found in the specified directory.")
224
+ return []
225
+
226
+ plan_path = Path(path) / TEXT_PLAN_FILE
227
+ contexts = load_valid_plan(
228
+ plan_path, "text", path, output_dir, documents, TextContext
229
+ )
230
+ if contexts is not None:
231
+ typer.echo(f"Reusing validated document context from {plan_path}.")
232
+ else:
233
+ typer.echo(f"Found {len(documents)} document(s) in '{path}'")
234
+ try:
235
+ contexts, failures = classify_documents(documents, out, config)
236
+ except EnvironmentError as exc:
237
+ typer.echo(f"Error: {exc}", err=True)
238
+ raise typer.Exit(code=1)
239
+ for document, error in failures:
240
+ typer.echo(f" [!] Skipping {document.name}: {error}", err=True)
241
+ if contexts and len(contexts) == len(documents):
242
+ save_plan(plan_path, "text", path, output_dir, documents, contexts)
243
+
244
+ if not contexts:
245
+ typer.echo("No documents were categorized.")
246
+ return []
247
+
248
+ moves = plan_moves(contexts, out)
249
+ if dry_run:
250
+ typer.echo(f"\n[Dry Run] Planned document moves ({len(moves)} files):")
251
+ for src, dst in moves:
252
+ typer.echo(f" {src} → {dst}")
253
+ return contexts
254
+
255
+ summary = execute_moves(moves, copy=not move)
256
+ folders = {destination.parent.name for _, destination in moves}
257
+ if not summary["failed"]:
258
+ save_managed_text_dirs(out, folders)
259
+ verb = "Moved" if move else "Copied"
260
+ typer.echo(f"{verb} {summary['moved'] if move else summary['copied']} document(s).")
261
+ return contexts
262
+
263
+
186
264
  @app.command(name="refactor-images")
187
265
  def refactor_images(
188
266
  path: str = typer.Argument(".", help="Directory containing images to organize"),
@@ -211,12 +289,12 @@ def refactor_images(
211
289
  min_confidence: float = typer.Option(
212
290
  40.0,
213
291
  "--min-confidence",
214
- help="Minimum Imagga tag confidence to retain",
292
+ help="Minimum Imagga tag confidence to retain (hosted backend only)",
215
293
  ),
216
- ) -> None:
294
+ ) -> list:
217
295
  """
218
- Generate fresh Imagga and Gemini context, then organise images into
219
- Gemini-selected subdirectories. The context cache is refreshed on every run.
296
+ Generate fresh context with the configured image backend, then organise
297
+ images into model-selected subdirectories. The context cache is refreshed on every run.
220
298
 
221
299
  By default, images are MOVED. Use --copy to preserve source files.
222
300
  """
@@ -238,19 +316,31 @@ def refactor_images(
238
316
  if excluded:
239
317
  typer.echo(f"Skipping {len(excluded)} folder(s) created by an earlier Refactor run.")
240
318
 
241
- typer.echo("Generating image context with Imagga and Gemini...")
242
- contexts = get_image_context(
243
- path=path,
244
- output=str(context_path),
245
- min_confidence=min_confidence,
246
- verbose=False,
247
- category_root=output_dir,
248
- write_context=not dry_run,
319
+ patterns = load_ignore_patterns()
320
+ current_images = scan_images(path, patterns, excluded)
321
+ contexts = load_valid_plan(
322
+ context_path, "image", path, output_dir, current_images, ImageContext
249
323
  )
324
+ if contexts is not None:
325
+ typer.echo(f"Reusing validated image context from {context_path}.")
326
+ else:
327
+ typer.echo("Generating image context with the configured backend...")
328
+ contexts = get_image_context(
329
+ path=path,
330
+ output=str(context_path),
331
+ min_confidence=min_confidence,
332
+ verbose=False,
333
+ category_root=output_dir,
334
+ write_context=False,
335
+ )
336
+ if contexts and len(contexts) == len(current_images):
337
+ save_plan(
338
+ context_path, "image", path, output_dir, current_images, contexts
339
+ )
250
340
 
251
341
  if not contexts:
252
342
  typer.echo("Context cache is empty. Nothing to organize.")
253
- raise typer.Exit(code=0)
343
+ return []
254
344
 
255
345
  moves = plan_moves(contexts, out)
256
346
 
@@ -265,7 +355,7 @@ def refactor_images(
265
355
  for src, dst in moves:
266
356
  typer.echo(f" {src.name:<40} {dst}")
267
357
  typer.echo(f"\n No files were {verb_past}.")
268
- raise typer.Exit(code=0)
358
+ return contexts
269
359
 
270
360
  folders = {dst.parent.name for _, dst in moves}
271
361
  typer.echo(f"\nWill {action_word.lower()} {len(moves)} image(s) into {len(folders)} folder(s):")
@@ -285,6 +375,29 @@ def refactor_images(
285
375
  typer.echo(f" Failed : {summary['failed']} file(s)")
286
376
  typer.echo(f" Output : {out.resolve()}")
287
377
  typer.echo(f"{'─' * 52}")
378
+ return contexts
379
+
380
+
381
+ @app.command(name="now")
382
+ def refactor_now(
383
+ path: str = typer.Argument(".", help="Directory containing documents and images"),
384
+ output_dir: str = typer.Option(".", "--output-dir", "-d"),
385
+ dry_run: bool = typer.Option(False, "--dry-run"),
386
+ move: bool = typer.Option(True, "--move/--copy"),
387
+ min_confidence: float = typer.Option(40.0, "--min-confidence"),
388
+ ) -> None:
389
+ """Categorize supported documents first, then images in a mixed folder."""
390
+ typer.echo("\n[1/2] Categorizing documents")
391
+ refactor_text(path=path, output_dir=output_dir, dry_run=dry_run, move=move)
392
+ typer.echo("\n[2/2] Categorizing images")
393
+ refactor_images(
394
+ path=path,
395
+ context=DEFAULT_CACHE_FILE,
396
+ output_dir=output_dir,
397
+ dry_run=dry_run,
398
+ move=move,
399
+ min_confidence=min_confidence,
400
+ )
288
401
 
289
402
 
290
403
  if __name__ == "__main__":
refactor/config.py CHANGED
@@ -27,7 +27,7 @@ def default_config() -> dict[str, Any]:
27
27
  "local": {
28
28
  "runtime": "ollama",
29
29
  "recommended_model": "",
30
- "status": "stub",
30
+ "status": "not_configured",
31
31
  },
32
32
  }
33
33
 
refactor/gemini.py CHANGED
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import re
8
+ import base64
8
9
  from pathlib import Path
9
10
 
10
11
  import requests
@@ -101,3 +102,55 @@ class GeminiFolderClassifier:
101
102
  for index, context in enumerate(contexts):
102
103
  context.primary_folder = folders[index]
103
104
  return contexts
105
+
106
+ def choose_document_folder(
107
+ self,
108
+ file_path: Path,
109
+ extracted_text: str,
110
+ category_root: Path,
111
+ is_pdf: bool = False,
112
+ ) -> str:
113
+ """Choose one folder from a document's text or native PDF content."""
114
+ prompt = (
115
+ "Choose one concise, lowercase category folder for this document. "
116
+ "Use letters, numbers, spaces, underscores, or hyphens only; return "
117
+ "JSON only as {\"folder\":\"example\"}. Prefer an existing category "
118
+ "when it fits. Do not use a filename or path.\n\n"
119
+ f"Existing categories: {json.dumps(sorted(entry.name for entry in category_root.iterdir() if entry.is_dir()))}\n"
120
+ f"Filename: {file_path.name}\n"
121
+ )
122
+ parts = [{"text": prompt + ("The PDF is attached." if is_pdf else extracted_text)}]
123
+ if is_pdf:
124
+ parts.append(
125
+ {
126
+ "inlineData": {
127
+ "mimeType": "application/pdf",
128
+ "data": base64.b64encode(file_path.read_bytes()).decode("ascii"),
129
+ }
130
+ }
131
+ )
132
+
133
+ payload = {
134
+ "contents": [{"parts": parts}],
135
+ "generationConfig": {
136
+ "responseMimeType": "application/json",
137
+ "temperature": 0,
138
+ },
139
+ }
140
+ url = (
141
+ "https://generativelanguage.googleapis.com/v1beta/models/"
142
+ f"{self.model}:generateContent"
143
+ )
144
+ response = requests.post(
145
+ url, headers={"x-goog-api-key": self.api_key}, json=payload, timeout=60
146
+ )
147
+ if not response.ok:
148
+ raise requests.HTTPError(
149
+ f"Gemini document classification returned {response.status_code}: {response.text}",
150
+ response=response,
151
+ )
152
+ try:
153
+ text = response.json()["candidates"][0]["content"]["parts"][0]["text"]
154
+ return _safe_folder_name(str(json.loads(text)["folder"]))
155
+ except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
156
+ raise ValueError("Gemini returned an invalid document-folder response") from exc
refactor/hardware.py CHANGED
@@ -20,26 +20,60 @@ def _memory_gb() -> float:
20
20
  return round((pagesize * pages) / (1024**3), 2)
21
21
 
22
22
 
23
- def _nvidia_vram_gb() -> float | None:
24
- if not shutil.which("nvidia-smi"):
25
- return None
26
-
23
+ def _run(command: list[str]) -> str | None:
24
+ """Return command output without allowing hardware probes to fail setup."""
27
25
  try:
28
26
  result = subprocess.run(
29
- [
30
- "nvidia-smi",
31
- "--query-gpu=memory.total",
32
- "--format=csv,noheader,nounits",
33
- ],
27
+ command,
34
28
  capture_output=True,
35
29
  text=True,
36
30
  check=True,
31
+ timeout=5,
37
32
  )
38
- except Exception:
33
+ except (OSError, subprocess.SubprocessError):
34
+ return None
35
+ return result.stdout
36
+
37
+
38
+ def _has_nvidia_gpu() -> bool:
39
+ """Detect NVIDIA hardware even when the driver utility cannot report VRAM."""
40
+ if shutil.which("nvidia-smi") and _run(["nvidia-smi", "-L"]):
41
+ return True
42
+
43
+ if os.name != "nt":
44
+ pci_devices = _run(["lspci"])
45
+ if pci_devices and "nvidia" in pci_devices.lower():
46
+ return True
47
+ nvidia_devices = "/proc/driver/nvidia/gpus"
48
+ try:
49
+ if os.path.isdir(nvidia_devices) and os.listdir(nvidia_devices):
50
+ return True
51
+ except OSError:
52
+ pass
53
+ else:
54
+ output = _run(["wmic", "path", "win32_VideoController", "get", "name"])
55
+ if output and "nvidia" in output.lower():
56
+ return True
57
+
58
+ return False
59
+
60
+
61
+ def _nvidia_vram_gb() -> float | None:
62
+ if not shutil.which("nvidia-smi"):
63
+ return None
64
+
65
+ output = _run(
66
+ [
67
+ "nvidia-smi",
68
+ "--query-gpu=memory.total",
69
+ "--format=csv,noheader,nounits",
70
+ ]
71
+ )
72
+ if output is None:
39
73
  return None
40
74
 
41
75
  values = []
42
- for line in result.stdout.splitlines():
76
+ for line in output.splitlines():
43
77
  line = line.strip()
44
78
  if not line:
45
79
  continue
@@ -56,7 +90,7 @@ def detect_hardware() -> HardwareSpec:
56
90
  return HardwareSpec(
57
91
  cpu_cores=os.cpu_count() or 1,
58
92
  memory_gb=_memory_gb(),
59
- has_nvidia_gpu=vram is not None,
93
+ has_nvidia_gpu=_has_nvidia_gpu(),
60
94
  gpu_vram_gb=vram,
61
95
  )
62
96
 
@@ -64,23 +98,32 @@ def detect_hardware() -> HardwareSpec:
64
98
  def recommend_local_model(spec: HardwareSpec) -> tuple[str, str]:
65
99
  if spec.has_nvidia_gpu and (spec.gpu_vram_gb or 0) >= 8:
66
100
  return (
67
- "llava:7b",
68
- "Good fit for GPU-backed image understanding with balanced quality/speed.",
101
+ "gemma3:4b",
102
+ "Multimodal instruction-following model suitable for both document and image organization.",
103
+ )
104
+
105
+ if spec.has_nvidia_gpu:
106
+ return (
107
+ "gemma3:4b",
108
+ "A multimodal model is required for reliable structured document and image categorization; "
109
+ "verify available memory if NVIDIA VRAM cannot be read.",
69
110
  )
70
111
 
71
112
  if spec.memory_gb >= 16:
72
113
  return (
73
- "llava:7b-q4",
74
- "Reasonable CPU-only option with quantization for 16GB+ RAM systems.",
114
+ "gemma3:4b",
115
+ "Multimodal model with enough context for document and image organization on 16GB+ RAM.",
75
116
  )
76
117
 
77
118
  if spec.memory_gb >= 8:
78
119
  return (
79
- "moondream",
80
- "Lightweight option for lower-memory systems focused on image captioning.",
120
+ "gemma3:4b",
121
+ "Smallest recommended multimodal option for structured document and image organization; "
122
+ "expect slower operation on CPU-only systems.",
81
123
  )
82
124
 
83
125
  return (
84
- "nanollava",
85
- "Very small fallback model for constrained hardware; expect limited accuracy.",
126
+ "gemma3:4b",
127
+ "Refactor requires a multimodal instruction-following model for all supported file types. "
128
+ "This system may not have enough memory; use hosted mode or add memory if the model cannot run.",
86
129
  )
refactor/plan_cache.py ADDED
@@ -0,0 +1,66 @@
1
+ """Persistent, source-validated plans for dry-run reuse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+ from pathlib import Path
8
+ from typing import Any, Type
9
+
10
+
11
+ def _snapshot(files: list[Path]) -> list[dict[str, Any]]:
12
+ return [
13
+ {
14
+ "path": str(file_path.resolve()),
15
+ "size": file_path.stat().st_size,
16
+ "mtime_ns": file_path.stat().st_mtime_ns,
17
+ }
18
+ for file_path in sorted(files)
19
+ ]
20
+
21
+
22
+ def save_plan(
23
+ output_path: Path,
24
+ kind: str,
25
+ source_dir: str,
26
+ output_dir: str,
27
+ files: list[Path],
28
+ contexts: list[Any],
29
+ ) -> None:
30
+ output_path.parent.mkdir(parents=True, exist_ok=True)
31
+ payload = {
32
+ "kind": kind,
33
+ "source_dir": str(Path(source_dir).resolve()),
34
+ "output_dir": str(Path(output_dir).resolve()),
35
+ "snapshot": _snapshot(files),
36
+ "items": [asdict(context) for context in contexts],
37
+ }
38
+ output_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
39
+
40
+
41
+ def load_valid_plan(
42
+ input_path: Path,
43
+ kind: str,
44
+ source_dir: str,
45
+ output_dir: str,
46
+ files: list[Path],
47
+ context_type: Type[Any],
48
+ ) -> list[Any] | None:
49
+ """Return cached contexts only if they exactly match the current source."""
50
+ if not input_path.exists():
51
+ return None
52
+ try:
53
+ payload = json.loads(input_path.read_text(encoding="utf-8"))
54
+ except (OSError, json.JSONDecodeError):
55
+ return None
56
+ if (
57
+ payload.get("kind") != kind
58
+ or payload.get("source_dir") != str(Path(source_dir).resolve())
59
+ or payload.get("output_dir") != str(Path(output_dir).resolve())
60
+ or payload.get("snapshot") != _snapshot(files)
61
+ ):
62
+ return None
63
+ try:
64
+ return [context_type(**item) for item in payload["items"]]
65
+ except (KeyError, TypeError):
66
+ return None
refactor/setup.py CHANGED
@@ -1,12 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import shutil
5
+ import subprocess
4
6
  import sys
5
7
 
6
8
  import typer
7
9
 
8
10
  from refactor.config import config_exists, default_config, load_config, save_config, save_env_vars
9
11
  from refactor.hardware import detect_hardware, recommend_local_model
12
+ from refactor.shim import ollama_model_available, ollama_server_available
10
13
 
11
14
 
12
15
  def _interactive() -> bool:
@@ -80,7 +83,7 @@ def run_setup() -> dict:
80
83
  typer.echo("\nRefactor setup")
81
84
  typer.echo("Choose backend:")
82
85
  typer.echo(" 1) Hosted model (bring your own API keys)")
83
- typer.echo(" 2) Local multimodal recommendation (stub integration)")
86
+ typer.echo(" 2) Local multimodal model with Ollama")
84
87
 
85
88
  choice = typer.prompt("Select option", default="1").strip()
86
89
 
@@ -120,14 +123,41 @@ def run_setup() -> dict:
120
123
  typer.echo(f"- Model: {model}")
121
124
  typer.echo(f"- Why: {reason}")
122
125
  typer.echo("- Suggested runtime: ollama")
123
- typer.echo("- Example install: ollama pull " + model)
126
+ typer.echo("- Model command: ollama pull " + model)
124
127
 
125
128
  config["backend"] = "local"
126
129
  config["setup_skipped"] = False
127
130
  config["local"]["runtime"] = "ollama"
128
131
  config["local"]["recommended_model"] = model
129
- config["local"]["status"] = "stub"
130
- typer.echo("Local backend configured with stub shim for now.")
132
+ config["local"]["status"] = "not_ready"
133
+
134
+ if not shutil.which("ollama"):
135
+ typer.echo("\nOllama is not installed, so Refactor cannot use a local model yet.")
136
+ typer.echo("Install Ollama from: https://ollama.com/download")
137
+ typer.echo("Then run:")
138
+ typer.echo(f" ollama pull {model}")
139
+ typer.echo(" refactor setup")
140
+ elif not ollama_server_available():
141
+ typer.echo("\nOllama is installed but its local server is not running.")
142
+ typer.echo("Start it in another terminal with:")
143
+ typer.echo(" ollama serve")
144
+ typer.echo("Then rerun: refactor setup")
145
+ elif ollama_model_available(model):
146
+ config["local"]["status"] = "ready"
147
+ typer.echo(f"\nOllama model '{model}' is installed and ready to use.")
148
+ else:
149
+ typer.echo(f"\nOllama is ready, but '{model}' is not installed.")
150
+ if typer.confirm(f"Download '{model}' now?", default=True):
151
+ result = subprocess.run(["ollama", "pull", model], check=False)
152
+ if result.returncode == 0 and ollama_model_available(model):
153
+ config["local"]["status"] = "ready"
154
+ typer.echo(f"Local model '{model}' is ready to use.")
155
+ else:
156
+ typer.echo(
157
+ f"Could not verify '{model}'. Run `ollama pull {model}` and then `refactor setup` again."
158
+ )
159
+ else:
160
+ typer.echo(f"Run `ollama pull {model}` and then `refactor setup` when ready.")
131
161
 
132
162
  else:
133
163
  raise typer.BadParameter("Invalid option. Choose 1 or 2.")
refactor/shim.py CHANGED
@@ -1,12 +1,22 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
+ import base64
5
+ import json
4
6
  from pathlib import Path
7
+ from urllib.error import URLError
8
+ from urllib.request import Request, urlopen
5
9
 
6
10
  from refactor.imagga import ImageContext, ImaggaClient
7
11
  from refactor.gemini import GeminiFolderClassifier
8
12
 
9
13
 
14
+ def _safe_folder_name(value: str) -> str:
15
+ value = value.strip().lower()
16
+ safe = "".join(char for char in value if char.isalnum() or char in " -_")
17
+ return "_".join(safe.split()).strip("_-") or "uncategorized"
18
+
19
+
10
20
  class ImageClassifier(ABC):
11
21
  @abstractmethod
12
22
  def classify_image(self, file_path: Path) -> ImageContext:
@@ -26,27 +36,251 @@ class HostedImageClassifier(ImageClassifier):
26
36
  return GeminiFolderClassifier().choose_folders(contexts, category_root)
27
37
 
28
38
 
29
- class LocalStubImageClassifier(ImageClassifier):
39
+ OLLAMA_URL = "http://127.0.0.1:11434"
40
+
41
+ # ``format: "json"`` ensures only syntactically valid JSON. A schema also
42
+ # constrains keys and array length, which is essential for small local models.
43
+ IMAGE_CONTEXT_SCHEMA = {
44
+ "type": "object",
45
+ "properties": {
46
+ "caption": {"type": "string"},
47
+ "tags": {
48
+ "type": "array",
49
+ "items": {"type": "string"},
50
+ "minItems": 1,
51
+ "maxItems": 5,
52
+ },
53
+ "folder": {"type": "string"},
54
+ },
55
+ "required": ["caption", "tags", "folder"],
56
+ "additionalProperties": False,
57
+ }
58
+ FOLDER_SCHEMA = {
59
+ "type": "object",
60
+ "properties": {"folder": {"type": "string"}},
61
+ "required": ["folder"],
62
+ "additionalProperties": False,
63
+ }
64
+ FOLDER_BATCH_SCHEMA = {
65
+ "type": "object",
66
+ "properties": {
67
+ "folders": {
68
+ "type": "array",
69
+ "items": {
70
+ "type": "object",
71
+ "properties": {"id": {"type": "integer"}, "folder": {"type": "string"}},
72
+ "required": ["id", "folder"],
73
+ "additionalProperties": False,
74
+ },
75
+ }
76
+ },
77
+ "required": ["folders"],
78
+ "additionalProperties": False,
79
+ }
80
+
81
+
82
+ def _parse_ollama_json(response: dict, description: str) -> dict:
83
+ """Return one JSON object without exposing unbounded model output on errors."""
84
+ if response.get("error"):
85
+ raise RuntimeError(f"Ollama {description} request failed: {response['error']}")
86
+ content = response.get("message", {}).get("content", "")
87
+ try:
88
+ parsed = json.loads(content)
89
+ except (TypeError, json.JSONDecodeError) as exc:
90
+ preview = str(content).replace("\n", " ")[:300]
91
+ reason = response.get("done_reason", "unknown")
92
+ raise RuntimeError(
93
+ f"Ollama returned invalid {description} JSON "
94
+ f"(done_reason={reason}, preview={preview!r})"
95
+ ) from exc
96
+ if not isinstance(parsed, dict):
97
+ raise RuntimeError(f"Ollama returned a non-object {description} response")
98
+ return parsed
99
+
100
+
101
+ def _ollama_request(path: str, payload: dict | None = None) -> dict:
102
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
103
+ request = Request(
104
+ f"{OLLAMA_URL}{path}",
105
+ data=data,
106
+ headers={"Content-Type": "application/json"},
107
+ method="POST" if data is not None else "GET",
108
+ )
109
+ try:
110
+ with urlopen(request, timeout=120) as response:
111
+ return json.loads(response.read().decode("utf-8"))
112
+ except (URLError, OSError, json.JSONDecodeError) as exc:
113
+ raise EnvironmentError(
114
+ "Cannot reach Ollama at http://127.0.0.1:11434. Start it with `ollama serve`."
115
+ ) from exc
116
+
117
+
118
+ def ollama_server_available() -> bool:
119
+ try:
120
+ _ollama_request("/api/tags")
121
+ except EnvironmentError:
122
+ return False
123
+ return True
124
+
125
+
126
+ def ollama_model_available(model_name: str) -> bool:
127
+ try:
128
+ response = _ollama_request("/api/tags")
129
+ except EnvironmentError:
130
+ return False
131
+ installed = {model.get("name", "") for model in response.get("models", [])}
132
+ return model_name in installed or f"{model_name}:latest" in installed
133
+
134
+
135
+ class OllamaImageClassifier(ImageClassifier):
30
136
  def __init__(self, model_name: str) -> None:
31
137
  self.model_name = model_name
32
138
 
33
139
  def classify_image(self, file_path: Path) -> ImageContext:
34
- name_tokens = [t for t in file_path.stem.lower().replace("-", "_").split("_") if t]
35
- tags = name_tokens[:3] if name_tokens else ["unclassified"]
36
- primary = tags[0] if tags else "unclassified"
140
+ try:
141
+ encoded_image = base64.b64encode(file_path.read_bytes()).decode("ascii")
142
+ except OSError as exc:
143
+ raise RuntimeError(f"Could not read image: {file_path}") from exc
144
+
145
+ prompt = (
146
+ "Analyze this image for file organization. Return one JSON object matching "
147
+ "the supplied schema: a short caption, 1 to 5 distinct concise lowercase "
148
+ "tags, and one broad reusable folder name. Do not repeat tags or add keys."
149
+ )
150
+ response = _ollama_request(
151
+ "/api/chat",
152
+ {
153
+ "model": self.model_name,
154
+ "stream": False,
155
+ "format": IMAGE_CONTEXT_SCHEMA,
156
+ "options": {"temperature": 0, "num_predict": 160},
157
+ "think": False,
158
+ "messages": [{"role": "user", "content": prompt, "images": [encoded_image]}],
159
+ },
160
+ )
161
+ result = _parse_ollama_json(response, "image classification")
162
+
163
+ raw_tags = result.get("tags")
164
+ if not isinstance(raw_tags, list) or not all(isinstance(tag, str) for tag in raw_tags):
165
+ raise RuntimeError("Ollama image classification did not return a string tag list")
166
+ tags = list(dict.fromkeys(tag.strip().lower() for tag in raw_tags if tag.strip()))[:5]
167
+ caption = str(result.get("caption", "")).strip()
168
+ primary = _safe_folder_name(str(result.get("folder", "")))
169
+ if not tags:
170
+ tags = ["unclassified"]
171
+ if not caption:
172
+ caption = "No caption returned by local model."
173
+ if primary == "uncategorized":
174
+ primary = tags[0]
37
175
 
38
176
  return ImageContext(
39
177
  file_path=str(file_path.resolve()),
40
- upload_id="local-stub",
178
+ upload_id="ollama-local",
41
179
  tags=tags,
42
180
  confidences={tag: 0.0 for tag in tags},
43
- caption=(
44
- f"Local stub classification using '{self.model_name}'. "
45
- "Install and connect a local multimodal runtime in a future step."
46
- ),
181
+ caption=caption,
47
182
  primary_folder=primary,
48
183
  )
49
184
 
185
+ def choose_folders(
186
+ self, contexts: list[ImageContext], category_root: Path
187
+ ) -> list[ImageContext]:
188
+ if not contexts:
189
+ return contexts
190
+ existing = (
191
+ sorted(entry.name for entry in category_root.iterdir() if entry.is_dir())
192
+ if category_root.exists()
193
+ else []
194
+ )
195
+ images = [
196
+ {"id": index, "filename": Path(context.file_path).name,
197
+ "tags": context.tags, "caption": context.caption}
198
+ for index, context in enumerate(contexts)
199
+ ]
200
+ prompt = (
201
+ "Choose exactly one concise, lowercase category folder for every image. "
202
+ "Prefer an existing category when it accurately fits; otherwise create a "
203
+ "new, broad and reusable category. Folder names must be a single relative "
204
+ "directory name (letters, numbers, spaces, underscores, or hyphens only). "
205
+ "Return exactly one id/folder object for every image and no other keys.\n\n"
206
+ f"Existing categories: {json.dumps(existing)}\n"
207
+ f"Images and local semantic context: {json.dumps(images, ensure_ascii=False)}"
208
+ )
209
+ response = _ollama_request(
210
+ "/api/chat",
211
+ {
212
+ "model": self.model_name,
213
+ "stream": False,
214
+ "format": FOLDER_BATCH_SCHEMA,
215
+ "options": {"temperature": 0, "num_predict": max(80, len(contexts) * 40)},
216
+ "think": False,
217
+ "messages": [{"role": "user", "content": prompt}],
218
+ },
219
+ )
220
+ result = _parse_ollama_json(response, "image folder-selection")
221
+ try:
222
+ choices = result["folders"]
223
+ folders = {
224
+ choice.get("id"): _safe_folder_name(str(choice.get("folder", "")))
225
+ for choice in choices
226
+ if isinstance(choice, dict) and isinstance(choice.get("id"), int)
227
+ }
228
+ except (KeyError, TypeError) as exc:
229
+ raise RuntimeError(
230
+ "Ollama image folder-selection response did not contain folders"
231
+ ) from exc
232
+ missing = [str(index) for index in range(len(contexts)) if index not in folders]
233
+ if missing:
234
+ raise RuntimeError("Ollama did not choose a folder for image id(s): " + ", ".join(missing))
235
+ for index, context in enumerate(contexts):
236
+ context.primary_folder = folders[index]
237
+ return contexts
238
+
239
+
240
+ class OllamaDocumentClassifier:
241
+ """Choose a document folder through a local Ollama text-capable model."""
242
+
243
+ def __init__(self, model_name: str) -> None:
244
+ self.model_name = model_name
245
+
246
+ def choose_document_folder(
247
+ self, file_path: Path, extracted_text: str, category_root: Path
248
+ ) -> str:
249
+ existing = (
250
+ sorted(entry.name for entry in category_root.iterdir() if entry.is_dir())
251
+ if category_root.exists()
252
+ else []
253
+ )
254
+ prompt = (
255
+ "Choose one concise, lowercase category folder for this document. "
256
+ "Prefer an existing category when it fits. The folder must not be a filename "
257
+ "or path. Return one JSON object matching the supplied schema and no other keys.\n\n"
258
+ f"Existing categories: {json.dumps(existing)}\n"
259
+ f"Filename: {file_path.name}\n"
260
+ f"Document text:\n{extracted_text}"
261
+ )
262
+ response = _ollama_request(
263
+ "/api/chat",
264
+ {
265
+ "model": self.model_name,
266
+ "stream": False,
267
+ "format": FOLDER_SCHEMA,
268
+ "options": {"temperature": 0, "num_predict": 80},
269
+ "think": False,
270
+ "messages": [{"role": "user", "content": prompt}],
271
+ },
272
+ )
273
+ result = _parse_ollama_json(response, "document classification")
274
+ try:
275
+ folder = result["folder"]
276
+ except KeyError as exc:
277
+ raise RuntimeError(
278
+ "Ollama document classification response did not contain a folder"
279
+ ) from exc
280
+ if not isinstance(folder, str):
281
+ raise RuntimeError("Ollama document classification returned a non-string folder")
282
+ return _safe_folder_name(folder)
283
+
50
284
 
51
285
  def build_image_classifier(config: dict, min_confidence: float) -> ImageClassifier:
52
286
  backend = config.get("backend", "unconfigured")
@@ -56,9 +290,37 @@ def build_image_classifier(config: dict, min_confidence: float) -> ImageClassifi
56
290
 
57
291
  if backend == "local":
58
292
  local_cfg = config.get("local", {})
59
- model_name = local_cfg.get("recommended_model") or "local-stub"
60
- return LocalStubImageClassifier(model_name=model_name)
293
+ model_name = local_cfg.get("recommended_model")
294
+ if not model_name:
295
+ raise EnvironmentError("No local Ollama model is configured. Run `refactor setup`.")
296
+ if not ollama_server_available():
297
+ raise EnvironmentError("Ollama is not running. Start it with `ollama serve`.")
298
+ if not ollama_model_available(model_name):
299
+ raise EnvironmentError(
300
+ f"Ollama model '{model_name}' is not installed. Run `ollama pull {model_name}`."
301
+ )
302
+ return OllamaImageClassifier(model_name=model_name)
303
+
304
+ raise EnvironmentError(
305
+ "Refactor setup is incomplete. Run 'refactor setup' to configure hosted or local backend."
306
+ )
61
307
 
308
+
309
+ def build_document_classifier(config: dict):
310
+ """Return the configured backend's folder classifier for documents."""
311
+ if config.get("backend") == "hosted":
312
+ return GeminiFolderClassifier()
313
+ if config.get("backend") == "local":
314
+ model_name = config.get("local", {}).get("recommended_model")
315
+ if not model_name:
316
+ raise EnvironmentError("No local Ollama model is configured. Run `refactor setup`.")
317
+ if not ollama_server_available():
318
+ raise EnvironmentError("Ollama is not running. Start it with `ollama serve`.")
319
+ if not ollama_model_available(model_name):
320
+ raise EnvironmentError(
321
+ f"Ollama model '{model_name}' is not installed. Run `ollama pull {model_name}`."
322
+ )
323
+ return OllamaDocumentClassifier(model_name)
62
324
  raise EnvironmentError(
63
325
  "Refactor setup is incomplete. Run 'refactor setup' to configure hosted or local backend."
64
326
  )
@@ -67,7 +329,12 @@ def build_image_classifier(config: dict, min_confidence: float) -> ImageClassifi
67
329
  def choose_category_folders(
68
330
  config: dict, contexts: list[ImageContext], category_root: Path
69
331
  ) -> list[ImageContext]:
70
- """Use Gemini for hosted classifications; local mode keeps its stub result."""
332
+ """Use the configured model to consistently choose category folders."""
71
333
  if config.get("backend") == "hosted":
72
334
  return GeminiFolderClassifier().choose_folders(contexts, category_root)
335
+ if config.get("backend") == "local":
336
+ model_name = config.get("local", {}).get("recommended_model")
337
+ if not model_name:
338
+ raise EnvironmentError("No local Ollama model is configured. Run `refactor setup`.")
339
+ return OllamaImageClassifier(model_name).choose_folders(contexts, category_root)
73
340
  return contexts
refactor/text.py ADDED
@@ -0,0 +1,103 @@
1
+ """Document discovery, text extraction, and configured-backend categorization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import zipfile
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from xml.etree import ElementTree
10
+
11
+ from refactor.ignore import should_ignore
12
+ from refactor.shim import build_document_classifier
13
+
14
+ TEXT_EXTENSIONS = {".txt", ".md", ".pdf", ".docx"}
15
+ TEXT_CATEGORY_MANIFEST = ".refactor_text_categories.json"
16
+ MAX_TEXT_CHARS = 60_000
17
+ MAX_PDF_BYTES = 50 * 1024 * 1024
18
+
19
+
20
+ @dataclass
21
+ class TextContext:
22
+ file_path: str
23
+ primary_folder: str
24
+
25
+
26
+ def managed_text_dirs(output_dir: Path) -> set[Path]:
27
+ manifest = output_dir / TEXT_CATEGORY_MANIFEST
28
+ if not manifest.exists():
29
+ return set()
30
+ try:
31
+ folders = json.loads(manifest.read_text(encoding="utf-8")).get("folders", [])
32
+ except (OSError, json.JSONDecodeError):
33
+ return set()
34
+ return {
35
+ (output_dir / folder).resolve() for folder in folders
36
+ if isinstance(folder, str) and (output_dir / folder).is_dir()
37
+ }
38
+
39
+
40
+ def save_managed_text_dirs(output_dir: Path, folders: set[str]) -> None:
41
+ (output_dir / TEXT_CATEGORY_MANIFEST).write_text(
42
+ json.dumps({"folders": sorted(folders)}, indent=2) + "\n", encoding="utf-8"
43
+ )
44
+
45
+
46
+ def scan_documents(path: str, patterns: list[str], excluded_dirs: set[Path]) -> list[Path]:
47
+ documents: list[Path] = []
48
+ excluded_dirs = {directory.resolve() for directory in excluded_dirs}
49
+ for item in Path(path).rglob("*"):
50
+ if item.is_dir() or item.suffix.lower() not in TEXT_EXTENSIONS:
51
+ continue
52
+ if any(directory in item.resolve().parents for directory in excluded_dirs):
53
+ continue
54
+ if not should_ignore(item.name, patterns):
55
+ documents.append(item)
56
+ return documents
57
+
58
+
59
+ def extract_text(file_path: Path) -> str:
60
+ """Extract text locally from supported document formats."""
61
+ if file_path.suffix.lower() in {".txt", ".md"}:
62
+ return file_path.read_text(encoding="utf-8", errors="replace")[:MAX_TEXT_CHARS]
63
+ if file_path.suffix.lower() == ".docx":
64
+ with zipfile.ZipFile(file_path) as document:
65
+ root = ElementTree.fromstring(document.read("word/document.xml"))
66
+ text = " ".join(node.text or "" for node in root.iter() if node.tag.endswith("}t"))
67
+ return text[:MAX_TEXT_CHARS]
68
+ if file_path.suffix.lower() == ".pdf":
69
+ try:
70
+ from pypdf import PdfReader
71
+ except ImportError as exc:
72
+ raise EnvironmentError(
73
+ "PDF classification needs the `pypdf` package. Reinstall refactor-cli to include it."
74
+ ) from exc
75
+ reader = PdfReader(file_path)
76
+ return "\n".join(page.extract_text() or "" for page in reader.pages)[:MAX_TEXT_CHARS]
77
+ return ""
78
+
79
+
80
+ def classify_documents(
81
+ documents: list[Path], category_root: Path, config: dict
82
+ ) -> tuple[list[TextContext], list[tuple[Path, Exception]]]:
83
+ classifier = build_document_classifier(config)
84
+ results: list[TextContext] = []
85
+ failures: list[tuple[Path, Exception]] = []
86
+ for document in documents:
87
+ try:
88
+ is_pdf = document.suffix.lower() == ".pdf"
89
+ if is_pdf and document.stat().st_size > MAX_PDF_BYTES:
90
+ raise ValueError("PDF exceeds Refactor's 50 MB document input limit")
91
+ if config.get("backend") == "hosted":
92
+ # Preserve the hosted flow's native PDF input capability.
93
+ folder = classifier.choose_document_folder(
94
+ document, extract_text(document), category_root, is_pdf=is_pdf
95
+ )
96
+ else:
97
+ folder = classifier.choose_document_folder(
98
+ document, extract_text(document), category_root
99
+ )
100
+ results.append(TextContext(str(document.resolve()), folder))
101
+ except Exception as exc:
102
+ failures.append((document, exc))
103
+ return results, failures
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactor-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: CLI tool to organize files using AI
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -8,6 +8,7 @@ License-File: LICENSE
8
8
  Requires-Dist: typer
9
9
  Requires-Dist: requests
10
10
  Requires-Dist: python-dotenv
11
+ Requires-Dist: pypdf
11
12
  Dynamic: license-file
12
13
 
13
14
  # refactor.ai
@@ -22,11 +23,15 @@ The CLI entry point in [refactor/cli.py](refactor/cli.py) provides:
22
23
  - `tree` — prints a recursive directory tree through [refactor/tree.py](refactor/tree.py).
23
24
  - `scan` — collects basic file and directory metadata through [refactor/scanner.py](refactor/scanner.py) and [refactor/metadata.py](refactor/metadata.py).
24
25
  - `setup` — configures a multimodal backend (`hosted` or `local`), persisted to `~/.refactor/config.json`.
25
- - `get-image-context` — obtains Imagga V3 tags/captions, asks Gemini to choose a
26
- destination category, and writes JSON context.
27
- - `refactor-images` — automatically refreshes image context, then moves images
28
- into Gemini-selected existing or new category folders (`--copy` is available
29
- when the originals must be retained).
26
+ - `get-image-context` — obtains image tags/captions and asks the configured
27
+ backend to choose a destination category, then writes JSON context.
28
+ - `refactor-images` — automatically refreshes image context with the configured
29
+ backend, then moves images into model-selected category folders (`--copy` is
30
+ available when the originals must be retained).
31
+ - `refactor-text` — uses the configured backend to categorize `.txt`, `.md`,
32
+ `.pdf`, and `.docx` files.
33
+ - `now` — runs `refactor-text` and then `refactor-images`, so mixed folders are
34
+ organized in one invocation.
30
35
  - Path checks through [refactor/validation.py](refactor/validation.py), including existence, directory type, and read access.
31
36
  - Optional filename exclusions through [refactor/ignore.py](refactor/ignore.py).
32
37
 
@@ -39,10 +44,19 @@ After installing `refactor-cli`, the first CLI run prompts:
39
44
  1. **Hosted model (bring your own API keys)**
40
45
  - Prompts for `IMAGGA_API_KEY`, `IMAGGA_API_SECRET`, and `GEMINI_API_KEY`
41
46
  - Saves credentials to `~/.refactor/.env` with restricted file permissions
42
- 2. **Local multimodal recommendation (stub)**
43
- - Detects basic hardware (CPU, memory, NVIDIA GPU/VRAM if available)
44
- - Recommends an OSS multimodal model and records it in config
45
- - Uses a local shim stub today (full local runtime integration is a follow-up)
47
+ 2. **Local multimodal model with Ollama**
48
+ - Detects CPU, memory, and NVIDIA hardware (even if VRAM is temporarily unavailable)
49
+ - Recommends a vision model and verifies the local Ollama runtime
50
+ - Guides the user to install Ollama or pulls the recommended model when Ollama is ready
51
+ - Sends images to the local Ollama API for captions, tags, and category selection; documents are text-extracted locally and categorized through the same Ollama model. No image or document data is uploaded to Refactor services.
52
+
53
+ For a local setup, install [Ollama](https://ollama.com/download), then run the exact command printed by setup, for example:
54
+
55
+ ```shell
56
+ ollama pull gemma3:4b
57
+ ```
58
+
59
+ Run `refactor setup` again after the download finishes. Refactor verifies both the local Ollama service and the selected model before classifying images or documents.
46
60
 
47
61
  For the hosted workflow, local images are uploaded to Imagga and classified with
48
62
  the V3 `/tags` endpoint using `model=light` and `include_caption=true`. The
@@ -57,6 +71,16 @@ Run `refactor refactor-images ./incoming --output-dir ./organized` to generate
57
71
  fresh context and move the images. Add `--dry-run` to inspect destinations or
58
72
  `--copy` to retain the originals.
59
73
 
74
+ Run `refactor now ./incoming --output-dir ./organized` to organize supported
75
+ documents and images together. Hosted mode sends PDFs to Gemini as native
76
+ document input; local mode text-extracts PDFs, text, Markdown, and Word files
77
+ before sending their contents to Ollama.
78
+
79
+ `--dry-run` saves a source-validated organization plan. Running the same command
80
+ without `--dry-run` reuses that plan without another Imagga or Gemini request,
81
+ provided the source files and output directory are unchanged. Any added, removed,
82
+ or modified source file invalidates the plan and triggers fresh classification.
83
+
60
84
  ## Techniques used
61
85
 
62
86
  - **Recursive traversal with `pathlib`** — [refactor/scanner.py](refactor/scanner.py) uses [`Path.rglob()`](https://docs.python.org/3/library/pathlib.html#pathlib.Path.rglob) to discover nested paths. [refactor/tree.py](refactor/tree.py) recursively walks directory entries to render a text tree.
@@ -0,0 +1,24 @@
1
+ refactor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ refactor/cli.py,sha256=8B2DFFoLiQDMGV3We1BQq9uvjmU-piiksUOjg0cQ5VE,13808
3
+ refactor/config.py,sha256=bE45IvJHhrIfyVi7MZW_OUl8TdbavDJhdq-PKUOgOOw,1958
4
+ refactor/context_store.py,sha256=LE7ypaOOxqTS7V8YIs1QIyDSqKZRB5_Y6B8RFg40q3g,1453
5
+ refactor/gemini.py,sha256=TH7NBn9erhkV42JJIRH658w2dxXeT_pfG8HNh2Y7ACw,6242
6
+ refactor/hardware.py,sha256=qhJUq0aOB6FwEIHxQ2LbckIZo0DEYf0Xi4uKoN8uB90,3589
7
+ refactor/ignore.py,sha256=KBvsrb1ptHWuX7VU2qXuX4lYVP97fis8zuJdGKHrq-4,549
8
+ refactor/image_scanner.py,sha256=KsJu_DWTazssmlGVO3pvl3QrzLvefRYsLWjS-PLnYzY,1055
9
+ refactor/imagga.py,sha256=jn6t7IV63MOfF1dOzrF3ORyjaVdilPrY5FG59Xs_u3E,6472
10
+ refactor/metadata.py,sha256=fE7L46j85S5TbcYXQTQrT2AaXW4ut_Yg4JT7z_ZgyVU,248
11
+ refactor/organizer.py,sha256=EeLQKqFfmJCPXO3wqy1Zjd7iroV9wxiqmL7srU4XgDQ,3183
12
+ refactor/plan_cache.py,sha256=iX3hMbeUNpiBQDY0PD2NiARXX03HpJ4uOoXsUNuJ-7A,1899
13
+ refactor/scanner.py,sha256=epuaACLR845I0cYw9wTt5WyqPaKe_Ic0mrcuH-SB2jA,306
14
+ refactor/setup.py,sha256=HsxjvI314DlWQhD9MI7Cw1BecUhi2zNO1NGngbKU3wY,7063
15
+ refactor/shim.py,sha256=hRNdhJZ-iOkee18wUzy5ERaEl2JhzOtdy1VC2VJttQ0,13349
16
+ refactor/text.py,sha256=PbKV6jKIfYklBxle-jYk2TgzuRI0y1kyaPs3sT7LSjM,3960
17
+ refactor/tree.py,sha256=y1p2FGvmQQ5eAmuncjTqOURa6wKQ3US3VGV6pfi0DLY,446
18
+ refactor/validation.py,sha256=SM9VaPA-VF1-UuRQqmDuYs19rgVKtHmRkIGyz4wJ_I8,374
19
+ refactor_cli-0.3.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
20
+ refactor_cli-0.3.0.dist-info/METADATA,sha256=Az5HHH4RQXWXfd9LVbKSh3L7sukcT3s5MhNGz5S-6zs,7884
21
+ refactor_cli-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
22
+ refactor_cli-0.3.0.dist-info/entry_points.txt,sha256=oXn5HKS9c_VwsJ-QNKFd2GKXNhlWxr_tkfueSAmWr3M,46
23
+ refactor_cli-0.3.0.dist-info/top_level.txt,sha256=jCNAQcJGcXPbLuqADBNCQxentGSDGWA08qs5wEtb_AU,9
24
+ refactor_cli-0.3.0.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- refactor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- refactor/cli.py,sha256=T0HaNhFgZ8F9BcoWkrW76y6RbEzrVhIVsNcmQiP18GU,9331
3
- refactor/config.py,sha256=1fQPV2f_MkP9nuwBbD3qMEWKQZ13TF1UA3q9wP315yI,1948
4
- refactor/context_store.py,sha256=LE7ypaOOxqTS7V8YIs1QIyDSqKZRB5_Y6B8RFg40q3g,1453
5
- refactor/gemini.py,sha256=tMiBHMUUt615uX4YznuIQOve-AQCKYuMBnUvTYN-2PM,4044
6
- refactor/hardware.py,sha256=nZjLBON3I44MP458ojGuXZNXW_rtKyQHJDkhKlt8FLw,2101
7
- refactor/ignore.py,sha256=KBvsrb1ptHWuX7VU2qXuX4lYVP97fis8zuJdGKHrq-4,549
8
- refactor/image_scanner.py,sha256=KsJu_DWTazssmlGVO3pvl3QrzLvefRYsLWjS-PLnYzY,1055
9
- refactor/imagga.py,sha256=jn6t7IV63MOfF1dOzrF3ORyjaVdilPrY5FG59Xs_u3E,6472
10
- refactor/metadata.py,sha256=fE7L46j85S5TbcYXQTQrT2AaXW4ut_Yg4JT7z_ZgyVU,248
11
- refactor/organizer.py,sha256=EeLQKqFfmJCPXO3wqy1Zjd7iroV9wxiqmL7srU4XgDQ,3183
12
- refactor/scanner.py,sha256=epuaACLR845I0cYw9wTt5WyqPaKe_Ic0mrcuH-SB2jA,306
13
- refactor/setup.py,sha256=O98KOtOU8V8_WyMY1zR08LRN2oKq-nIpHDpQbvIscmk,5490
14
- refactor/shim.py,sha256=B0_I_jn3OTBm1dA7VI3--ECnARh8_jbcOZ2Yy1pk0Zk,2604
15
- refactor/tree.py,sha256=y1p2FGvmQQ5eAmuncjTqOURa6wKQ3US3VGV6pfi0DLY,446
16
- refactor/validation.py,sha256=SM9VaPA-VF1-UuRQqmDuYs19rgVKtHmRkIGyz4wJ_I8,374
17
- refactor_cli-0.2.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
18
- refactor_cli-0.2.0.dist-info/METADATA,sha256=Ydb0IKFPcDXl5Ew-M8KYuOB8Ry-X7dOJ3DpUtksonKM,6443
19
- refactor_cli-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
20
- refactor_cli-0.2.0.dist-info/entry_points.txt,sha256=oXn5HKS9c_VwsJ-QNKFd2GKXNhlWxr_tkfueSAmWr3M,46
21
- refactor_cli-0.2.0.dist-info/top_level.txt,sha256=jCNAQcJGcXPbLuqADBNCQxentGSDGWA08qs5wEtb_AU,9
22
- refactor_cli-0.2.0.dist-info/RECORD,,