codemem-mcp 0.1.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.
codemem/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """codemem: system-coding-memory. One memory for every project on every machine."""
2
+ __version__ = "0.1.0"
codemem/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """codemem command line: serve, backfill, gitea, docs, seed, scan, embed, reindex, backup, sync."""
2
+ import argparse, gzip, shutil, sys, time
3
+ from pathlib import Path
4
+ from . import config
5
+
6
+
7
+ def main(argv=None):
8
+ ap = argparse.ArgumentParser(prog="codemem", description=__doc__)
9
+ sub = ap.add_subparsers(dest="cmd", required=True)
10
+ sub.add_parser("serve", help="run the MCP server + web UI")
11
+ b = sub.add_parser("backfill", help="ingest all commits from every bare repo on the git server")
12
+ b.add_argument("repos", nargs="*")
13
+ sub.add_parser("gitea", help="pull descriptions/topics/urls from Gitea")
14
+ sub.add_parser("docs", help="(re)ingest markdown knowledge sources")
15
+ sub.add_parser("seed", help="seed howto notes and known assets")
16
+ s = sub.add_parser("scan", help="scan local directories for projects")
17
+ s.add_argument("roots", nargs="*", help="directories (default: registered scan roots, else config defaults)")
18
+ e = sub.add_parser("embed", help="embed index rows that lack vectors")
19
+ e.add_argument("--all", action="store_true", help="keep going until nothing is pending")
20
+ sub.add_parser("reindex", help="rebuild the FTS index from source tables (drops embeddings)")
21
+ bk = sub.add_parser("backup", help="consistent gzip copy of the database")
22
+ bk.add_argument("--dest", default=str(config.BACKUP_DIR))
23
+ bk.add_argument("--keep", type=int, default=30)
24
+ sub.add_parser("sync", help="docs + gitea + scan + embed: the timer job")
25
+ d = sub.add_parser("describe", help="draft descriptions for own projects that lack one (Ollama, tagged auto-described)")
26
+ d.add_argument("--dry-run", action="store_true")
27
+ dc = sub.add_parser("discover", help="mine own repos for reusable assets (tagged auto-discovered) and shared code links")
28
+ dc.add_argument("repos", nargs="*"); dc.add_argument("--no-describe", action="store_true", help="skip model drafts for files without a docstring")
29
+ rv = sub.add_parser("review", help="targeted model review: near-duplicate confirmation, thin descriptions on trusted assets")
30
+ rv.add_argument("--stage", type=int, choices=[1, 2], default=0, help="run one stage only (default both)")
31
+ rv.add_argument("--limit", type=int, default=40); rv.add_argument("--dry-run", action="store_true")
32
+ rv.add_argument("--machine", default="", help="stage 2: review every Python asset from this machine (source shipped by its agent)")
33
+ dn = sub.add_parser("delete-note", help="delete one note by id, with its index and embedding rows")
34
+ dn.add_argument("ids", nargs="+", type=int)
35
+ pg = sub.add_parser("purge", help="remove a project and everything attached to it (locations, assets, notes, commits, links)")
36
+ pg.add_argument("names", nargs="+")
37
+ sub.add_parser("trust", help="recompute trust scores for all projects and assets")
38
+ sub.add_parser("stats")
39
+ a = ap.parse_args(argv)
40
+
41
+ if a.cmd == "serve":
42
+ from .server import run; run()
43
+ elif a.cmd == "backfill":
44
+ from .gitsync import backfill
45
+ t = time.time(); n = backfill(a.repos or None); print(f"{n} new commits in {time.time()-t:.1f}s")
46
+ elif a.cmd == "gitea":
47
+ from .gitsync import gitea_sync; gitea_sync()
48
+ elif a.cmd == "docs":
49
+ from .knowledge import ingest_docs; ingest_docs()
50
+ elif a.cmd == "seed":
51
+ from .knowledge import seed; seed()
52
+ elif a.cmd == "scan":
53
+ from .scan import scan_local; print(scan_local(a.roots or None))
54
+ elif a.cmd == "embed":
55
+ from .search import embed_pending
56
+ total = 0
57
+ while True:
58
+ n = embed_pending(); total += n; print(f" embedded {n}")
59
+ if not n or not a.all:
60
+ break
61
+ print(f"{total} embedded")
62
+ elif a.cmd == "reindex":
63
+ from .store import reindex_all; reindex_all(); print("reindexed")
64
+ elif a.cmd == "backup":
65
+ backup(Path(a.dest), a.keep)
66
+ elif a.cmd == "sync":
67
+ from .knowledge import ingest_docs, seed
68
+ from .gitsync import gitea_sync, backfill
69
+ from .scan import scan_local
70
+ from .search import embed_pending
71
+ print("docs"); ingest_docs()
72
+ print("seed"); seed()
73
+ print("gitea"); gitea_sync()
74
+ print("backfill (catches anything the hook missed)"); backfill(log=lambda *_: None)
75
+ print("scan"); print(" ", scan_local())
76
+ from .discover import discover
77
+ print("discover"); print(" ", discover(describe=True, log=lambda *_: None))
78
+ from .trust import compute_all
79
+ print("trust"); compute_all()
80
+ print("embed");
81
+ while embed_pending(): pass
82
+ print("done")
83
+ elif a.cmd == "discover":
84
+ from .discover import discover; print(discover(a.repos or None, describe=not a.no_describe))
85
+ elif a.cmd == "review":
86
+ from .review import stage1, stage2
87
+ if a.stage in (0, 1): stage1(a.limit, a.dry_run)
88
+ if a.stage in (0, 2): stage2(a.limit, a.dry_run, machine=a.machine)
89
+ from .trust import compute_all; compute_all()
90
+ elif a.cmd == "delete-note":
91
+ from .store import delete_note
92
+ for i in a.ids:
93
+ n = delete_note(i)
94
+ print(f"deleted {i}: [{n['kind']}] {n['title'][:70]}" if n else f"no note {i}")
95
+ elif a.cmd == "purge":
96
+ from .store import purge_project
97
+ for n in a.names: print(purge_project(n))
98
+ elif a.cmd == "trust":
99
+ from .trust import compute_all; compute_all()
100
+ elif a.cmd == "describe":
101
+ from .describe import describe_all; print(f"{describe_all(dry=a.dry_run)} described")
102
+ elif a.cmd == "stats":
103
+ import json
104
+ from .server import stats; print(json.dumps(stats(), indent=1))
105
+
106
+
107
+ def backup(dest: Path, keep: int):
108
+ import sqlite3
109
+ from .db import connect
110
+ dest.mkdir(parents=True, exist_ok=True)
111
+ stamp = time.strftime("%Y%m%d")
112
+ tmp = dest / f"codemem-{stamp}.db"
113
+ dst = sqlite3.connect(str(tmp))
114
+ connect().backup(dst)
115
+ dst.close()
116
+ with open(tmp, "rb") as f, gzip.open(f"{tmp}.gz", "wb") as g:
117
+ shutil.copyfileobj(f, g)
118
+ tmp.unlink()
119
+ old = sorted(dest.glob("codemem-*.db.gz"))[:-keep]
120
+ for o in old:
121
+ o.unlink()
122
+ print(f"backup -> {tmp}.gz ({len(old)} pruned)")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
codemem/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """All tunables in one place. Every value can be overridden by an environment variable."""
2
+ import os, socket
3
+ from pathlib import Path
4
+
5
+ HERE = Path(__file__).resolve().parent.parent
6
+ DATA_DIR = Path(os.environ.get("CODEMEM_DATA", Path.home() / ".codemem"))
7
+ DB_PATH = Path(os.environ.get("CODEMEM_DB", DATA_DIR / "codemem.db"))
8
+ BACKUP_DIR = Path(os.environ.get("CODEMEM_BACKUP_DIR", DATA_DIR / "backups"))
9
+ HOST = os.environ.get("CODEMEM_HOST", "0.0.0.0")
10
+ PORT = int(os.environ.get("CODEMEM_PORT", "8055"))
11
+ MACHINE = os.environ.get("CODEMEM_MACHINE", socket.gethostname())
12
+
13
+ # The git host: a directory of bare repos served over SSH. codemem reads them directly, so it
14
+ # normally runs on the same machine. GIT_SSH_HOST is what clients put in their remote URLs
15
+ # (user@host) and is used to recognise our own remotes; empty means "same host, current user".
16
+ GIT_ROOT = Path(os.environ.get("GIT_ROOT", "/srv/git"))
17
+ GIT_SSH_HOST = os.environ.get("CODEMEM_GIT_SSH_HOST", socket.gethostname())
18
+ PUSH_LOG = GIT_ROOT / "logs" / "push.log"
19
+ GITEA_URL = os.environ.get("GITEA_URL", "http://127.0.0.1:3000").rstrip("/")
20
+ GITEA_PUBLIC_URL = os.environ.get("GITEA_PUBLIC_URL", GITEA_URL).rstrip("/")
21
+ GITEA_TOKEN_FILE = Path(os.environ.get("GITEA_TOKEN_FILE", GIT_ROOT / ".gitea-token"))
22
+
23
+ # GitHub/GitLab owners that are YOU (comma list). A clone whose remote owner is not listed here is
24
+ # vendor code. Empty (the default) disables owner-based vendor detection; mark vendor clones by hand.
25
+ OWN_REMOTE_OWNERS = {o.strip().lower() for o in os.environ.get("CODEMEM_OWN_OWNERS", "").split(",") if o.strip()}
26
+ # Names/paths that must never enter codemem at all. A regex matched case-insensitively against project
27
+ # names, remotes and scan paths, e.g. r"(^|[/\\_\-\s])(secret-project|other)([/\\_\-\s.]|$)" for whole
28
+ # path segments or name tokens. Empty (the default) excludes nothing. Purge existing rows with
29
+ # `codemem purge <project>`.
30
+ EXCLUDE_PATTERN = os.environ.get("CODEMEM_EXCLUDE", "")
31
+
32
+ # Default audience for new projects. "unrestricted" = personal work, no content filtering applied.
33
+ DEFAULT_AUDIENCE = os.environ.get("CODEMEM_DEFAULT_AUDIENCE", "unrestricted")
34
+
35
+ OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/")
36
+ EMBED_MODEL = os.environ.get("CODEMEM_EMBED_MODEL", "nomic-embed-text")
37
+ EMBED_ENABLED = os.environ.get("CODEMEM_EMBED", "1") not in ("0", "false", "no")
38
+ # Local model used to draft descriptions and run the targeted code review.
39
+ DESCRIBE_MODEL = os.environ.get("CODEMEM_DESCRIBE_MODEL", "qwen3-coder:30b")
40
+
41
+ # Optional markdown table of code-review grades: rows like `| ... | `project` | ... | ... | B |`.
42
+ # Feeds the trust score's review signal. Empty = no review signal.
43
+ REVIEW_TABLE = os.environ.get("CODEMEM_REVIEW_TABLE", "")
44
+
45
+ # Markdown that gets ingested as knowledge. Colon-separated globs, re-hashed on every run.
46
+ # Add the docs of your other infrastructure repos here so howto() can answer from them.
47
+ DOC_SOURCES = [s for s in os.environ.get("CODEMEM_DOC_SOURCES", ":".join([
48
+ str(HERE / "README.md"),
49
+ str(HERE / "docs" / "*.md"),
50
+ ])).split(":") if s]
51
+
52
+ # Default scan roots for THIS machine. Other machines add theirs via add_scan_root.
53
+ DEFAULT_SCAN_ROOTS = [s for s in os.environ.get("CODEMEM_SCAN_ROOTS", ":".join([
54
+ str(Path.home() / "projects"),
55
+ ])).split(":") if s]
56
+
57
+ SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "env", "__pycache__", ".cache", "dist",
58
+ "build", ".next", "site-packages", ".tox", ".mypy_cache",
59
+ "target", ".idea", ".vscode", "checkpoints", "models", "outputs", "output", "data",
60
+ "backup", "backups", "archive", "archives", "old", "_old", "mirror", "mirrors",
61
+ "PackageCache", "Library", "Temp", "Logs", "obj", "bin", ".godot", ".import"} # Unity/Godot/.NET caches
62
+ SCAN_MAX_DEPTH = 3
codemem/db.py ADDED
@@ -0,0 +1,199 @@
1
+ """SQLite schema, connection, and the single FTS5 index that spans every record type."""
2
+ import json, sqlite3, threading, time
3
+ from contextlib import contextmanager
4
+ from . import config
5
+
6
+ SCHEMA = """
7
+ PRAGMA journal_mode=WAL;
8
+ CREATE TABLE IF NOT EXISTS project (
9
+ id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL,
10
+ description TEXT DEFAULT '', purpose TEXT DEFAULT '', status TEXT DEFAULT 'active',
11
+ audience TEXT DEFAULT 'unrestricted', origin TEXT DEFAULT 'own', visibility TEXT DEFAULT 'private', maturity TEXT DEFAULT '', maturity_note TEXT DEFAULT '',
12
+ trust INTEGER, trust_breakdown TEXT DEFAULT '', verified_at TEXT, verified_note TEXT DEFAULT '',
13
+ tags TEXT DEFAULT '', languages TEXT DEFAULT '',
14
+ remote_url TEXT DEFAULT '', gitea_url TEXT DEFAULT '', github_url TEXT DEFAULT '',
15
+ first_commit TEXT, last_commit TEXT, commit_count INTEGER DEFAULT 0,
16
+ created_at TEXT, updated_at TEXT);
17
+ CREATE TABLE IF NOT EXISTS location (
18
+ id INTEGER PRIMARY KEY, project_id INTEGER REFERENCES project(id) ON DELETE CASCADE,
19
+ machine TEXT NOT NULL, path TEXT NOT NULL, is_git INTEGER DEFAULT 0,
20
+ remote_url TEXT DEFAULT '', branch TEXT DEFAULT '', dirty INTEGER DEFAULT 0,
21
+ last_local_commit TEXT, file_count INTEGER, languages TEXT DEFAULT '',
22
+ key_files TEXT DEFAULT '', readme_head TEXT DEFAULT '', last_scanned TEXT,
23
+ UNIQUE(machine, path));
24
+ CREATE TABLE IF NOT EXISTS asset (
25
+ id INTEGER PRIMARY KEY, project_id INTEGER REFERENCES project(id) ON DELETE SET NULL,
26
+ name TEXT NOT NULL, kind TEXT NOT NULL, path TEXT DEFAULT '', machine TEXT DEFAULT '',
27
+ description TEXT DEFAULT '', usage TEXT DEFAULT '', tags TEXT DEFAULT '',
28
+ maturity TEXT DEFAULT '', maturity_note TEXT DEFAULT '',
29
+ last_changed TEXT, change_count INTEGER, blob_hash TEXT DEFAULT '', size INTEGER, symbols TEXT DEFAULT '',
30
+ imports TEXT DEFAULT '', func_hashes TEXT DEFAULT '', signatures TEXT DEFAULT '', imported_by INTEGER DEFAULT 0,
31
+ review TEXT DEFAULT '', reviewed_at TEXT, source_head TEXT DEFAULT '',
32
+ trust INTEGER, trust_breakdown TEXT DEFAULT '', verified_at TEXT, verified_note TEXT DEFAULT '',
33
+ created_at TEXT, updated_at TEXT, UNIQUE(name, kind));
34
+ CREATE TABLE IF NOT EXISTS note (
35
+ id INTEGER PRIMARY KEY, project_id INTEGER REFERENCES project(id) ON DELETE SET NULL,
36
+ kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT DEFAULT '', tags TEXT DEFAULT '',
37
+ machine TEXT DEFAULT '', session_id TEXT DEFAULT '', path TEXT DEFAULT '', created_at TEXT);
38
+ CREATE TABLE IF NOT EXISTS "commit" (
39
+ id INTEGER PRIMARY KEY, project_id INTEGER REFERENCES project(id) ON DELETE CASCADE,
40
+ hash TEXT NOT NULL, author TEXT, date TEXT, message TEXT, files TEXT DEFAULT '',
41
+ ref TEXT DEFAULT '', pushed_at TEXT, pushed_by TEXT, pushed_from TEXT,
42
+ UNIQUE(project_id, hash));
43
+ CREATE TABLE IF NOT EXISTS link (
44
+ id INTEGER PRIMARY KEY, from_kind TEXT, from_id INTEGER, to_kind TEXT, to_id INTEGER,
45
+ relation TEXT NOT NULL, note TEXT DEFAULT '', created_at TEXT,
46
+ UNIQUE(from_kind, from_id, to_kind, to_id, relation));
47
+ CREATE TABLE IF NOT EXISTS doc (
48
+ id INTEGER PRIMARY KEY, source TEXT NOT NULL UNIQUE, title TEXT, body TEXT, hash TEXT,
49
+ updated_at TEXT);
50
+ CREATE TABLE IF NOT EXISTS scan_root (
51
+ id INTEGER PRIMARY KEY, machine TEXT NOT NULL, path TEXT NOT NULL, enabled INTEGER DEFAULT 1,
52
+ note TEXT DEFAULT '', last_scanned TEXT, UNIQUE(machine, path));
53
+ CREATE TABLE IF NOT EXISTS embedding (
54
+ kind TEXT NOT NULL, ref_id INTEGER NOT NULL, model TEXT NOT NULL, hash TEXT, vec BLOB,
55
+ PRIMARY KEY(kind, ref_id));
56
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
57
+ kind UNINDEXED, ref_id UNINDEXED, project UNINDEXED, audience UNINDEXED, maturity UNINDEXED, origin UNINDEXED, title, body, tags,
58
+ tokenize='porter unicode61');
59
+ CREATE INDEX IF NOT EXISTS idx_commit_date ON "commit"(date);
60
+ CREATE INDEX IF NOT EXISTS idx_commit_project ON "commit"(project_id, date);
61
+ CREATE INDEX IF NOT EXISTS idx_note_project ON note(project_id, created_at);
62
+ """
63
+
64
+ _lock = threading.RLock()
65
+ _conn = None
66
+
67
+
68
+ def now():
69
+ return time.strftime("%Y-%m-%dT%H:%M:%S%z")
70
+
71
+
72
+ def connect():
73
+ global _conn
74
+ with _lock:
75
+ if _conn is None:
76
+ config.DB_PATH.parent.mkdir(parents=True, exist_ok=True)
77
+ _conn = sqlite3.connect(str(config.DB_PATH), check_same_thread=False, timeout=30)
78
+ _conn.row_factory = sqlite3.Row
79
+ _conn.execute("PRAGMA foreign_keys=ON")
80
+ _conn.executescript(SCHEMA)
81
+ _migrate(_conn)
82
+ return _conn
83
+
84
+
85
+ # Controlled vocabulary for maturity. Free text is accepted but these are what filters and the UI expect.
86
+ MATURITY = {
87
+ "authoritative": "the one to use; maintained and trusted",
88
+ "usable": "works, reuse with normal care",
89
+ "experimental": "unproven; may be worth building on",
90
+ "antiquated": "works but superseded or dated; prefer something newer",
91
+ "sunset": "being retired; do not build on it",
92
+ "broken": "does not work as is",
93
+ "junk": "not worth reusing; kept for reference only",
94
+ }
95
+ MATURITY_RANK = {"authoritative": 1.6, "usable": 1.2, "experimental": 1.0, "": 1.0,
96
+ "antiquated": 0.7, "sunset": 0.6, "broken": 0.5, "junk": 0.35}
97
+
98
+
99
+ def _migrate(c):
100
+ """Add columns introduced after the first release; recreate the FTS table if its shape changed."""
101
+ def cols(t):
102
+ return {r[1] for r in c.execute(f'PRAGMA table_info("{t}")')}
103
+ for table in ("project", "asset"):
104
+ for col in ("maturity", "maturity_note"):
105
+ if col not in cols(table):
106
+ c.execute(f'ALTER TABLE "{table}" ADD COLUMN {col} TEXT DEFAULT \'\'')
107
+ for col, typ in (("last_changed", "TEXT"), ("change_count", "INTEGER"), ("blob_hash", "TEXT DEFAULT ''"), ("size", "INTEGER"), ("symbols", "TEXT DEFAULT ''"),
108
+ ("imports", "TEXT DEFAULT ''"), ("func_hashes", "TEXT DEFAULT ''"), ("signatures", "TEXT DEFAULT ''"), ("imported_by", "INTEGER DEFAULT 0"),
109
+ ("review", "TEXT DEFAULT ''"), ("reviewed_at", "TEXT"), ("source_head", "TEXT DEFAULT ''")):
110
+ if col not in cols("asset"):
111
+ c.execute(f"ALTER TABLE asset ADD COLUMN {col} {typ}")
112
+ for table in ("project", "asset"):
113
+ for col, typ in (("trust", "INTEGER"), ("trust_breakdown", "TEXT DEFAULT ''"), ("verified_at", "TEXT"), ("verified_note", "TEXT DEFAULT ''")):
114
+ if col not in cols(table):
115
+ c.execute(f'ALTER TABLE "{table}" ADD COLUMN {col} {typ}')
116
+ if "visibility" not in cols("project"):
117
+ c.execute("ALTER TABLE project ADD COLUMN visibility TEXT DEFAULT 'private'")
118
+ if "origin" not in cols("project"):
119
+ c.execute("ALTER TABLE project ADD COLUMN origin TEXT DEFAULT 'own'")
120
+ # 2026-09-06: the default audience label changed from 'personal' to 'unrestricted' (see docs/USER_GUIDE.md).
121
+ c.execute("UPDATE project SET audience='unrestricted' WHERE audience='personal'")
122
+ c.execute("UPDATE search_index SET audience='unrestricted' WHERE audience='personal'")
123
+ if "maturity" not in cols("search_index") or "origin" not in cols("search_index"):
124
+ c.execute("DROP TABLE search_index")
125
+ c.executescript(SCHEMA)
126
+ c.commit()
127
+ from .store import reindex_all
128
+ reindex_all(keep_embeddings=True)
129
+ c.commit()
130
+
131
+
132
+ @contextmanager
133
+ def tx():
134
+ """Serialised write transaction. SQLite is fine with this at our scale."""
135
+ c = connect()
136
+ with _lock:
137
+ try:
138
+ yield c
139
+ c.commit()
140
+ except Exception:
141
+ c.rollback()
142
+ raise
143
+
144
+
145
+ def q(sql, params=()):
146
+ with _lock:
147
+ return [dict(r) for r in connect().execute(sql, params).fetchall()]
148
+
149
+
150
+ def one(sql, params=()):
151
+ rows = q(sql, params)
152
+ return rows[0] if rows else None
153
+
154
+
155
+ # ---- search index maintenance ------------------------------------------------
156
+ # Every record type writes itself into search_index through index_item, so search
157
+ # needs no joins and new kinds need no schema change.
158
+
159
+ def index_item(c, kind, ref_id, title, body, tags="", project="", maturity=None):
160
+ """Index one record. Audience is inherited from the project so filtering needs no joins."""
161
+ audience, pmat, origin = "", "", "own"
162
+ if project:
163
+ r = c.execute("SELECT audience, maturity, origin FROM project WHERE name=? COLLATE NOCASE", (project,)).fetchone()
164
+ audience, pmat, origin = ((r[0] or ""), (r[1] or ""), (r[2] or "own")) if r else ("", "", "own")
165
+ if maturity is None:
166
+ maturity = pmat # records inherit their project's rating unless they carry their own
167
+ c.execute("DELETE FROM search_index WHERE kind=? AND ref_id=?", (kind, ref_id))
168
+ c.execute("INSERT INTO search_index(kind, ref_id, project, audience, maturity, origin, title, body, tags) VALUES (?,?,?,?,?,?,?,?,?)",
169
+ (kind, ref_id, project or "", audience, maturity or "", origin, title or "", (body or "")[:20000], tags or ""))
170
+ if not KEEP_EMBEDDINGS:
171
+ c.execute("DELETE FROM embedding WHERE kind=? AND ref_id=?", (kind, ref_id))
172
+
173
+
174
+ KEEP_EMBEDDINGS = False
175
+
176
+
177
+ def unindex(c, kind, ref_id):
178
+ c.execute("DELETE FROM search_index WHERE kind=? AND ref_id=?", (kind, ref_id))
179
+ c.execute("DELETE FROM embedding WHERE kind=? AND ref_id=?", (kind, ref_id))
180
+
181
+
182
+ def rebuild_index():
183
+ """Drop and recreate search_index from the source tables."""
184
+ from .store import reindex_all
185
+ reindex_all()
186
+
187
+
188
+ def tags_norm(tags):
189
+ if not tags:
190
+ return ""
191
+ if isinstance(tags, str):
192
+ parts = [t.strip() for t in tags.replace(";", ",").split(",")]
193
+ else:
194
+ parts = [str(t).strip() for t in tags]
195
+ return ",".join(sorted({p.lower() for p in parts if p}))
196
+
197
+
198
+ def dumps(x):
199
+ return json.dumps(x, ensure_ascii=False, default=str)
codemem/describe.py ADDED
@@ -0,0 +1,71 @@
1
+ """Draft a description and purpose for projects that lack one, using a local Ollama model.
2
+
3
+ Only touches own projects whose description is empty, tiny, or just the name. Everything it writes
4
+ is tagged `auto-described` so a human-written replacement is easy to spot and the draft is never
5
+ mistaken for a considered statement. Re-running skips projects already tagged.
6
+ """
7
+ import json, urllib.request
8
+ from pathlib import Path
9
+ from . import config
10
+ from .db import q, one
11
+ from .store import upsert_project
12
+
13
+ MODEL = config.DESCRIBE_MODEL
14
+ PROMPT = """You are cataloguing a developer's personal projects. From the evidence below, write:
15
+ 1. "description": one sentence, max 25 words, what the project IS (tool/app/library/experiment) and what it does. No marketing words.
16
+ 2. "purpose": one sentence, max 25 words, why someone would reach for it, or what problem it solved.
17
+ If the evidence is too thin, say so in the description ("Unclear: ...") rather than guessing.
18
+ Answer with JSON only: {{"description": "...", "purpose": "..."}}
19
+
20
+ Project name: {name}
21
+ Languages: {languages}
22
+ Key files: {key_files}
23
+ Path: {path}
24
+ README / CLAUDE.md head:
25
+ {readme}
26
+ Recent commit subjects:
27
+ {commits}
28
+ """
29
+
30
+
31
+ def candidates():
32
+ return q("""SELECT p.id, p.name, p.description, p.tags, l.path, l.languages, l.key_files, l.readme_head
33
+ FROM project p LEFT JOIN location l ON l.project_id=p.id AND l.machine=?
34
+ WHERE p.origin='own' AND (p.description='' OR length(p.description)<25 OR p.description=p.name COLLATE NOCASE)
35
+ AND (',' || p.tags || ',') NOT LIKE '%,auto-described,%' GROUP BY p.id ORDER BY p.name""", (config.MACHINE,))
36
+
37
+
38
+ def ask(prompt):
39
+ req = urllib.request.Request(f"{config.OLLAMA_URL}/api/chat", data=json.dumps({
40
+ "model": MODEL, "stream": False, "format": "json", "options": {"temperature": 0.2, "num_ctx": 8192},
41
+ "messages": [{"role": "user", "content": prompt}]}).encode(), headers={"Content-Type": "application/json"})
42
+ with urllib.request.urlopen(req, timeout=300) as r:
43
+ return json.loads(json.load(r)["message"]["content"])
44
+
45
+
46
+ def describe_all(log=print, dry=False, limit=100):
47
+ done = 0
48
+ for c in candidates()[:limit]:
49
+ commits = q('SELECT substr(message,1,90) AS m FROM "commit" WHERE project_id=? ORDER BY date DESC LIMIT 8', (c["id"],))
50
+ readme = c["readme_head"] or ""
51
+ if c["path"] and not readme:
52
+ for f in ("README.md", "CLAUDE.md"):
53
+ fp = Path(c["path"]) / f
54
+ if fp.exists():
55
+ readme = "\n".join(fp.read_text(errors="replace").splitlines()[:25])[:2000]; break
56
+ if not readme and not commits and not c["path"]:
57
+ log(f" {c['name']}: no evidence, skipped"); continue
58
+ prompt = PROMPT.format(name=c["name"], languages=c["languages"] or "?", key_files=c["key_files"] or "?",
59
+ path=c["path"] or "(bare repo only)", readme=readme or "(none)",
60
+ commits="\n".join(x["m"].splitlines()[0] for x in commits) or "(none)")
61
+ try:
62
+ out = ask(prompt)
63
+ except Exception as e:
64
+ log(f" {c['name']}: model error {e}"); continue
65
+ desc, purpose = (out.get("description") or "").strip()[:300], (out.get("purpose") or "").strip()[:300]
66
+ log(f" {c['name']}: {desc}")
67
+ if not dry and desc:
68
+ tags = ",".join(t for t in [c["tags"], "auto-described"] if t)
69
+ upsert_project(c["name"], description=desc, purpose=purpose, tags=tags)
70
+ done += 1
71
+ return done
codemem/discover.py ADDED
@@ -0,0 +1,180 @@
1
+ """Asset discovery on the server, and ingestion of assets posted by remote agents.
2
+
3
+ Server side: mine every own repo (bare repo in GIT_ROOT, else the local working copy) with the
4
+ shared rules in discover_core. Remote side: client/codemem_agent.py runs the same rules on its
5
+ machine and posts assets inside its scan payload; ingest_assets() below stores them identically.
6
+
7
+ Then link retooling across ALL projects and machines from what is stored: identical blob hashes,
8
+ and Python files whose symbol sets overlap by half or more, become `shares-code-with` links.
9
+ """
10
+ import json, urllib.request
11
+ from collections import defaultdict
12
+ from pathlib import Path
13
+ from . import config
14
+ from .db import q, one, tx
15
+ from .store import upsert_asset, add_link, get_project
16
+ from .discover_core import scan_bare, scan_worktree, asset_name
17
+
18
+ DESCRIBE_MODEL = config.DESCRIBE_MODEL
19
+
20
+
21
+ def draft_description(path, text_head):
22
+ prompt = ("One sentence, max 25 words, plain: what does this file do and when would someone reuse it? "
23
+ "If unclear, start with 'Unclear:'. Answer JSON {\"description\": \"...\"}\n\nFile: " + path + "\n\n" + text_head[:4000])
24
+ try:
25
+ req = urllib.request.Request(f"{config.OLLAMA_URL}/api/chat", data=json.dumps({
26
+ "model": DESCRIBE_MODEL, "stream": False, "format": "json", "options": {"temperature": 0.2, "num_ctx": 8192},
27
+ "messages": [{"role": "user", "content": prompt}]}).encode(), headers={"Content-Type": "application/json"})
28
+ with urllib.request.urlopen(req, timeout=300) as r:
29
+ return (json.loads(json.load(r)["message"]["content"]).get("description") or "").strip()[:300]
30
+ except Exception:
31
+ return ""
32
+
33
+
34
+ def ingest_assets(project, assets, machine="", base_path="", describe=True):
35
+ """Store asset dicts (from scan_bare/scan_worktree or a remote payload) for a project.
36
+ Existing assets at the same path are refreshed; human descriptions are never overwritten."""
37
+ created = updated = drafted = 0
38
+ for a in assets:
39
+ path = a["path"].replace("\\", "/")
40
+ existing = one("SELECT * FROM asset WHERE project_id=? AND (path=? OR path LIKE ?)", (project["id"], path, f"%/{path}"))
41
+ fields = {"last_changed": a.get("last_changed"), "change_count": a.get("change_count"), "blob_hash": a.get("blob_hash") or "",
42
+ "size": a.get("size"), "symbols": ",".join(a.get("symbols") or [])[:2000],
43
+ "imports": ",".join(a.get("imports") or [])[:1000], "func_hashes": json.dumps(a.get("func_hashes") or [])[:20000],
44
+ "signatures": "\n".join(a.get("signatures") or [])[:6000]}
45
+ if a.get("source_head"):
46
+ fields["source_head"] = a["source_head"][:16000]
47
+ if existing:
48
+ if not existing["usage"] and a.get("usage"):
49
+ fields["usage"] = a["usage"]
50
+ if base_path and not existing["machine"]:
51
+ fields["machine"] = machine; fields["path"] = f"{base_path}/{path}"
52
+ upsert_asset(existing["name"], existing["kind"], **fields)
53
+ updated += 1
54
+ else:
55
+ desc = a.get("description") or ""
56
+ tags = "auto-discovered"
57
+ if not desc and describe and a.get("head"):
58
+ desc = draft_description(path, a["head"])
59
+ if desc:
60
+ tags += ",auto-described"; drafted += 1
61
+ upsert_asset(asset_name(path, project["name"]), a["kind"], project=project["name"],
62
+ path=f"{base_path}/{path}" if base_path else path, machine=machine,
63
+ description=desc or f"Unclear: no docstring in {path}", usage=a.get("usage") or "", tags=tags, **fields)
64
+ created += 1
65
+ return {"created": created, "updated": updated, "drafted": drafted}
66
+
67
+
68
+ def repo_for_project(p):
69
+ bare = config.GIT_ROOT / f"{p['name']}.git"
70
+ if bare.is_dir():
71
+ return bare, True
72
+ loc = one("SELECT path FROM location WHERE project_id=? AND machine=? AND is_git=1", (p["id"], config.MACHINE))
73
+ if loc and Path(loc["path"], ".git").exists():
74
+ return Path(loc["path"]), False
75
+ return None, None
76
+
77
+
78
+ def discover(names=None, describe=True, log=print):
79
+ projects = q("SELECT * FROM project WHERE origin='own'" + (f" AND name IN ({','.join('?' * len(names))})" if names else ""), names or ())
80
+ tot = defaultdict(int)
81
+ for p in projects:
82
+ repo, bare = repo_for_project(p)
83
+ if not repo:
84
+ continue
85
+ local = one("SELECT path FROM location WHERE project_id=? AND machine=?", (p["id"], config.MACHINE))
86
+ assets = list(scan_bare(repo) if bare else scan_worktree(repo))
87
+ if describe:
88
+ _attach_heads(repo, bare, assets)
89
+ r = ingest_assets(p, assets, machine=config.MACHINE if local else "", base_path=local["path"] if local else "", describe=describe)
90
+ for k, v in r.items():
91
+ tot[k] += v
92
+ log(f" {p['name']}: {len(assets)} candidates")
93
+ tot["links"] = link_shared_code(log)
94
+ tot["projects"] = len(projects)
95
+ return dict(tot)
96
+
97
+
98
+ def _attach_heads(repo, bare, assets):
99
+ """For files without a description, fetch the first 60 lines so the model can draft one."""
100
+ from .discover_core import _git
101
+ for a in assets:
102
+ if a.get("description"):
103
+ continue
104
+ if bare:
105
+ raw = _git(repo, "cat-file", "-p", a["blob_hash"], binary=True)
106
+ else:
107
+ try:
108
+ raw = (Path(repo) / a["path"]).read_bytes()
109
+ except OSError:
110
+ raw = b""
111
+ a["head"] = "\n".join(raw.decode("utf-8", "replace").splitlines()[:60])
112
+
113
+
114
+ def link_shared_code(log=print):
115
+ """Cross-project, cross-machine: identical blobs and >=50% overlapping symbol sets."""
116
+ rows = q("""SELECT a.path, a.blob_hash, a.symbols, a.func_hashes, p.name AS project FROM asset a JOIN project p ON p.id=a.project_id
117
+ WHERE p.origin='own' AND a.tags LIKE '%auto-discovered%'""")
118
+ pairs = defaultdict(set)
119
+ byblob = defaultdict(list)
120
+ for r in rows:
121
+ if r["blob_hash"]:
122
+ byblob[r["blob_hash"]].append(r)
123
+ for locs in byblob.values():
124
+ projs = sorted({r["project"] for r in locs})
125
+ for i, a in enumerate(projs):
126
+ for b in projs[i + 1:]:
127
+ fa = next(r["path"] for r in locs if r["project"] == a); fb = next(r["path"] for r in locs if r["project"] == b)
128
+ pairs[(a, b)].add(f"identical: {Path(fa).name} = {Path(fb).name}" if Path(fa).name == Path(fb).name else f"identical: {fa} = {fb}")
129
+ syms = [(r["project"], r["path"], frozenset(r["symbols"].split(","))) for r in rows if r["symbols"] and r["symbols"].count(",") >= 4]
130
+ for i, (pa, fa, sa) in enumerate(syms):
131
+ for pb, fb, sb in syms[i + 1:]:
132
+ if pa == pb:
133
+ continue
134
+ j = len(sa & sb) / len(sa | sb)
135
+ if j >= 0.5:
136
+ a, b = sorted([pa, pb])
137
+ pairs[(a, b)].add(f"similar ({j:.0%} same functions): {fa} ~ {fb}")
138
+ # function level: the same normalized body in two different projects (renames and docstrings ignored)
139
+ byfunc = defaultdict(list)
140
+ for r in rows:
141
+ if not r["func_hashes"]:
142
+ continue
143
+ try:
144
+ for f in json.loads(r["func_hashes"]):
145
+ if f.get("lines", 0) >= 8:
146
+ byfunc[f["hash"]].append((r["project"], r["path"], f["name"], f["lines"]))
147
+ except ValueError:
148
+ pass
149
+ for hits in byfunc.values():
150
+ projs = sorted({h[0] for h in hits})
151
+ if len(projs) < 2:
152
+ continue
153
+ for i, a in enumerate(projs):
154
+ for b in projs[i + 1:]:
155
+ ha = next(h for h in hits if h[0] == a); hb = next(h for h in hits if h[0] == b)
156
+ if any(n.startswith("identical:") and Path(ha[1]).name in n for n in pairs[(a, b)]):
157
+ continue # whole file already reported
158
+ pairs[(a, b)].add(f"function {ha[2]} ({ha[3]} lines): {ha[1]} = {hb[1]}" + (f" as {hb[2]}" if hb[2] != ha[2] else ""))
159
+ # Keep model verdicts ("model: ...") from review stage 1 across rebuilds; only the computed parts are regenerated.
160
+ kept = {}
161
+ for l in q("SELECT from_id, to_id, note FROM link WHERE relation='shares-code-with'"):
162
+ parts = [x for x in (l["note"] or "").split("; ") if x.startswith("model:")]
163
+ if parts:
164
+ kept[(l["from_id"], l["to_id"])] = parts
165
+ with tx() as c:
166
+ c.execute("DELETE FROM link WHERE relation='shares-code-with'")
167
+ n = 0
168
+ seen = set()
169
+ for (a, b), files in pairs.items():
170
+ pa, pb = get_project(a), get_project(b)
171
+ if pa and pb:
172
+ key = (pa["id"], pb["id"]); seen.add(key)
173
+ note = "; ".join(kept.get(key, []) + sorted(files))[:2000]
174
+ add_link("project", pa["id"], "project", pb["id"], "shares-code-with", note)
175
+ n += 1
176
+ for key, parts in kept.items(): # verdict pairs whose computed evidence vanished still keep the verdict
177
+ if key not in seen:
178
+ add_link("project", key[0], "project", key[1], "shares-code-with", "; ".join(parts)[:2000]); n += 1
179
+ log(f" shared code: {n} project pairs linked")
180
+ return n