personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add photo intelligence: macOS Photos face-cluster → entity signals

New `sol call photos sync` command reads macOS Photos.sqlite face
cluster metadata, matches named faces to existing solstone entities
via fuzzy name resolution, and creates photo_cooccurrence entity
signals grouped by person per day.

Integrates photo_count into strength scoring with log2 scaling at
weight 3, surfaces it in get_entity_strength(), get_entity_intelligence(),
and the entity_strength CLI component breakdown.

+489 -3
+1 -1
apps/entities/call.py
··· 476 476 label = f"{name} ({eid})" if eid and eid != entity_slug(name) else name 477 477 typer.echo(f" {score:>8.1f} {label}") 478 478 typer.echo( 479 - f" kg={r['kg_edge_count']} co={r['co_occurrence']} rec={r['recency']:.3f} obs={r['observation_depth']} fac={r['facet_breadth']}" 479 + f" kg={r['kg_edge_count']} co={r['co_occurrence']} pho={r['photo_count']} rec={r['recency']:.3f} obs={r['observation_depth']} fac={r['facet_breadth']}" 480 480 ) 481 481 482 482
+2
apps/photos/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc
+109
apps/photos/call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import sys 5 + 6 + import typer 7 + 8 + app = typer.Typer( 9 + name="photos", 10 + help="Photo intelligence from macOS Photos library.", 11 + no_args_is_help=True, 12 + ) 13 + 14 + 15 + @app.command("sync") 16 + def sync( 17 + library: str | None = typer.Option( 18 + None, 19 + "--library", 20 + help="Path to Photos.sqlite. Default: ~/Pictures/Photos Library.photoslibrary/database/Photos.sqlite", 21 + ), 22 + ) -> None: 23 + """Sync face clusters from macOS Photos to entity signals.""" 24 + if sys.platform != "darwin": 25 + typer.echo("This command requires macOS (Photos library is macOS-only).") 26 + raise typer.Exit(1) 27 + 28 + import logging 29 + from pathlib import Path 30 + 31 + from apps.photos.reader import read_face_clusters 32 + from think.entities.matching import build_name_resolution_map 33 + from think.indexer.journal import ( 34 + _insert_signal_row, 35 + _load_index_entity_dicts, 36 + get_journal_index, 37 + ) 38 + 39 + logger = logging.getLogger(__name__) 40 + 41 + if library is None: 42 + library = str( 43 + Path.home() 44 + / "Pictures" 45 + / "Photos Library.photoslibrary" 46 + / "database" 47 + / "Photos.sqlite" 48 + ) 49 + 50 + if not Path(library).exists(): 51 + typer.echo(f"Photos database not found: {library}") 52 + raise typer.Exit(1) 53 + 54 + try: 55 + clusters = read_face_clusters(library) 56 + except Exception as e: 57 + typer.echo(f"Error reading Photos database: {e}") 58 + raise typer.Exit(1) 59 + 60 + typer.echo(f"Found {len(clusters)} named face clusters.") 61 + if not clusters: 62 + return 63 + 64 + conn, _ = get_journal_index() 65 + try: 66 + entity_dicts = _load_index_entity_dicts(conn) 67 + face_names = [cluster["name"] for cluster in clusters] 68 + name_map = build_name_resolution_map(face_names, entity_dicts) 69 + 70 + matched = {name for name, entity_id in name_map.items() if entity_id} 71 + typer.echo(f"Matched {len(matched)} to entities.") 72 + 73 + if not matched: 74 + return 75 + 76 + conn.execute("DELETE FROM entity_signals WHERE signal_type='photo_cooccurrence'") 77 + 78 + signal_count = 0 79 + for cluster in clusters: 80 + if cluster["name"] not in matched: 81 + continue 82 + for day in cluster["days"]: 83 + _insert_signal_row( 84 + conn, 85 + { 86 + "signal_type": "photo_cooccurrence", 87 + "entity_name": cluster["name"], 88 + "entity_type": None, 89 + "target_name": None, 90 + "relationship_type": None, 91 + "day": day, 92 + "facet": None, 93 + "event_title": None, 94 + "event_type": None, 95 + "path": f"photos/{cluster['person_pk']}/{day}", 96 + }, 97 + ) 98 + signal_count += 1 99 + 100 + conn.commit() 101 + typer.echo(f"Created {signal_count} photo signals.") 102 + logger.info( 103 + "Photo sync: %d clusters, %d matched, %d signals", 104 + len(clusters), 105 + len(matched), 106 + signal_count, 107 + ) 108 + finally: 109 + conn.close()
+70
apps/photos/reader.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import sqlite3 5 + 6 + 7 + def read_face_clusters(db_path: str) -> list[dict]: 8 + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) 9 + try: 10 + def resolve_table(preferred: str, fallback: str) -> str: 11 + for table_name in (preferred, fallback): 12 + row = conn.execute( 13 + "SELECT name FROM sqlite_master WHERE type='table' AND name=?", 14 + (table_name,), 15 + ).fetchone() 16 + if row: 17 + return table_name 18 + raise sqlite3.OperationalError( 19 + f"Neither {preferred} nor {fallback} exists in Photos database" 20 + ) 21 + 22 + person_table = resolve_table("ZPERSON", "ZGENERICPERSON") 23 + asset_table = resolve_table("ZASSET", "ZGENERICASSET") 24 + 25 + people = conn.execute( 26 + f""" 27 + SELECT Z_PK, ZFULLNAME FROM {person_table} 28 + WHERE ZFULLNAME IS NOT NULL AND ZFULLNAME != '' 29 + AND ZMERGEDINTO IS NULL 30 + """ 31 + ).fetchall() 32 + 33 + clusters: list[dict] = [] 34 + for person_pk, name in people: 35 + count_row = conn.execute( 36 + f""" 37 + SELECT COUNT(*) 38 + FROM ZDETECTEDFACE 39 + JOIN {asset_table} ON ZDETECTEDFACE.ZASSET = {asset_table}.Z_PK 40 + WHERE ZDETECTEDFACE.ZPERSON = ? 41 + """, 42 + (person_pk,), 43 + ).fetchone() 44 + face_count = int(count_row[0] or 0) if count_row else 0 45 + if face_count == 0: 46 + continue 47 + 48 + day_rows = conn.execute( 49 + f""" 50 + SELECT DISTINCT date({asset_table}.ZDATECREATED + 978307200, 'unixepoch', 'localtime') 51 + FROM ZDETECTEDFACE 52 + JOIN {asset_table} ON ZDETECTEDFACE.ZASSET = {asset_table}.Z_PK 53 + WHERE ZDETECTEDFACE.ZPERSON = ? 54 + """, 55 + (person_pk,), 56 + ).fetchall() 57 + days = sorted(day.replace("-", "") for (day,) in day_rows if day) 58 + 59 + clusters.append( 60 + { 61 + "person_pk": int(person_pk), 62 + "name": name, 63 + "face_count": face_count, 64 + "days": days, 65 + } 66 + ) 67 + 68 + return clusters 69 + finally: 70 + conn.close()
+1
apps/photos/tests/__init__.py
··· 1 +
+263
apps/photos/tests/test_call.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import json 5 + import sqlite3 6 + import sys 7 + from pathlib import Path 8 + 9 + from typer.testing import CliRunner 10 + 11 + from think.call import call_app 12 + from think.indexer.journal import scan_journal 13 + 14 + runner = CliRunner() 15 + 16 + 17 + def _create_photos_db( 18 + db_path: Path, people: list[tuple[int, str | None]], faces: list[tuple[int, int, int]] 19 + ) -> None: 20 + conn = sqlite3.connect(db_path) 21 + try: 22 + conn.execute( 23 + "CREATE TABLE ZPERSON (Z_PK INTEGER PRIMARY KEY, ZFULLNAME TEXT, ZMERGEDINTO INTEGER)" 24 + ) 25 + conn.execute( 26 + "CREATE TABLE ZASSET (Z_PK INTEGER PRIMARY KEY, ZDATECREATED REAL)" 27 + ) 28 + conn.execute( 29 + "CREATE TABLE ZDETECTEDFACE (Z_PK INTEGER PRIMARY KEY, ZPERSON INTEGER, ZASSET INTEGER)" 30 + ) 31 + conn.executemany( 32 + "INSERT INTO ZPERSON (Z_PK, ZFULLNAME, ZMERGEDINTO) VALUES (?, ?, NULL)", 33 + people, 34 + ) 35 + conn.executemany( 36 + "INSERT INTO ZASSET (Z_PK, ZDATECREATED) VALUES (?, ?)", 37 + [ 38 + (1, 730000000), 39 + (2, 730086400), 40 + ], 41 + ) 42 + conn.executemany( 43 + "INSERT INTO ZDETECTEDFACE (Z_PK, ZPERSON, ZASSET) VALUES (?, ?, ?)", 44 + faces, 45 + ) 46 + conn.commit() 47 + finally: 48 + conn.close() 49 + 50 + 51 + def _create_journal(journal_dir: Path, entities: list[dict]) -> None: 52 + for entity in entities: 53 + entity_dir = journal_dir / "entities" / entity["id"] 54 + entity_dir.mkdir(parents=True, exist_ok=True) 55 + (entity_dir / "entity.json").write_text(json.dumps(entity), encoding="utf-8") 56 + scan_journal(str(journal_dir), full=True) 57 + 58 + 59 + def _photo_signal_count(journal_dir: Path) -> int: 60 + conn = sqlite3.connect(journal_dir / "indexer" / "journal.sqlite") 61 + try: 62 + row = conn.execute( 63 + "SELECT COUNT(*) FROM entity_signals WHERE signal_type='photo_cooccurrence'" 64 + ).fetchone() 65 + return int(row[0] or 0) if row else 0 66 + finally: 67 + conn.close() 68 + 69 + 70 + class TestPhotosSync: 71 + def test_non_macos_exits(self, monkeypatch): 72 + monkeypatch.setattr(sys, "platform", "linux") 73 + 74 + result = runner.invoke(call_app, ["photos", "sync"]) 75 + 76 + assert result.exit_code != 0 77 + assert "macOS" in result.output 78 + 79 + def test_missing_db_exits(self, tmp_path, monkeypatch): 80 + monkeypatch.setattr(sys, "platform", "darwin") 81 + 82 + result = runner.invoke( 83 + call_app, 84 + ["photos", "sync", "--library", str(tmp_path / "missing.sqlite")], 85 + ) 86 + 87 + assert result.exit_code != 0 88 + assert "not found" in result.output 89 + 90 + def test_sync_with_mock_photos_db(self, tmp_path, monkeypatch): 91 + photos_db = tmp_path / "Photos.sqlite" 92 + journal_dir = tmp_path / "journal" 93 + journal_dir.mkdir() 94 + _create_photos_db( 95 + photos_db, 96 + [(1, "Alice Johnson")], 97 + [(1, 1, 1), (2, 1, 2)], 98 + ) 99 + _create_journal( 100 + journal_dir, 101 + [{"id": "alice_johnson", "name": "Alice Johnson", "type": "Person"}], 102 + ) 103 + 104 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_dir)) 105 + monkeypatch.setattr(sys, "platform", "darwin") 106 + 107 + result = runner.invoke( 108 + call_app, 109 + ["photos", "sync", "--library", str(photos_db)], 110 + ) 111 + 112 + assert result.exit_code == 0 113 + assert "Found 1 named face clusters." in result.output 114 + assert "Matched 1 to entities." in result.output 115 + assert "Created 2 photo signals." in result.output 116 + 117 + conn = sqlite3.connect(journal_dir / "indexer" / "journal.sqlite") 118 + try: 119 + rows = conn.execute( 120 + """ 121 + SELECT entity_name, day, path 122 + FROM entity_signals 123 + WHERE signal_type='photo_cooccurrence' 124 + ORDER BY day 125 + """ 126 + ).fetchall() 127 + finally: 128 + conn.close() 129 + 130 + assert rows == [ 131 + ("Alice Johnson", "20240218", "photos/1/20240218"), 132 + ("Alice Johnson", "20240219", "photos/1/20240219"), 133 + ] 134 + 135 + def test_idempotent_sync(self, tmp_path, monkeypatch): 136 + photos_db = tmp_path / "Photos.sqlite" 137 + journal_dir = tmp_path / "journal" 138 + journal_dir.mkdir() 139 + _create_photos_db( 140 + photos_db, 141 + [(1, "Alice Johnson")], 142 + [(1, 1, 1), (2, 1, 2)], 143 + ) 144 + _create_journal( 145 + journal_dir, 146 + [{"id": "alice_johnson", "name": "Alice Johnson", "type": "Person"}], 147 + ) 148 + 149 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_dir)) 150 + monkeypatch.setattr(sys, "platform", "darwin") 151 + 152 + first = runner.invoke(call_app, ["photos", "sync", "--library", str(photos_db)]) 153 + second = runner.invoke( 154 + call_app, 155 + ["photos", "sync", "--library", str(photos_db)], 156 + ) 157 + 158 + assert first.exit_code == 0 159 + assert second.exit_code == 0 160 + assert _photo_signal_count(journal_dir) == 2 161 + 162 + def test_zero_faces(self, tmp_path, monkeypatch): 163 + photos_db = tmp_path / "Photos.sqlite" 164 + _create_photos_db(photos_db, [], []) 165 + 166 + monkeypatch.setattr(sys, "platform", "darwin") 167 + 168 + result = runner.invoke( 169 + call_app, 170 + ["photos", "sync", "--library", str(photos_db)], 171 + ) 172 + 173 + assert result.exit_code == 0 174 + assert "Found 0 named face clusters." in result.output 175 + 176 + def test_zero_matches(self, tmp_path, monkeypatch): 177 + photos_db = tmp_path / "Photos.sqlite" 178 + journal_dir = tmp_path / "journal" 179 + journal_dir.mkdir() 180 + _create_photos_db( 181 + photos_db, 182 + [(1, "Unmatched Person")], 183 + [(1, 1, 1)], 184 + ) 185 + 186 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_dir)) 187 + monkeypatch.setattr(sys, "platform", "darwin") 188 + 189 + result = runner.invoke( 190 + call_app, 191 + ["photos", "sync", "--library", str(photos_db)], 192 + ) 193 + 194 + assert result.exit_code == 0 195 + assert "Found 1 named face clusters." in result.output 196 + assert "Matched 0 to entities." in result.output 197 + 198 + def test_strength_includes_photo_count(self, tmp_path, monkeypatch): 199 + photos_db = tmp_path / "Photos.sqlite" 200 + journal_dir = tmp_path / "journal" 201 + journal_dir.mkdir() 202 + _create_photos_db( 203 + photos_db, 204 + [(1, "Alice Johnson")], 205 + [(1, 1, 1), (2, 1, 2)], 206 + ) 207 + _create_journal( 208 + journal_dir, 209 + [{"id": "alice_johnson", "name": "Alice Johnson", "type": "Person"}], 210 + ) 211 + 212 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_dir)) 213 + monkeypatch.setattr(sys, "platform", "darwin") 214 + 215 + runner.invoke(call_app, ["photos", "sync", "--library", str(photos_db)]) 216 + 217 + from think.indexer.journal import get_entity_strength 218 + 219 + results = get_entity_strength() 220 + alice = next((r for r in results if r.get("entity_id") == "alice_johnson"), None) 221 + assert alice is not None 222 + assert "photo_count" in alice 223 + assert alice["photo_count"] == 2 224 + assert alice["score"] > 0 225 + 226 + def test_fallback_tables(self, tmp_path, monkeypatch): 227 + photos_db = tmp_path / "Photos.sqlite" 228 + journal_dir = tmp_path / "journal" 229 + journal_dir.mkdir() 230 + conn = sqlite3.connect(photos_db) 231 + try: 232 + conn.execute( 233 + "CREATE TABLE ZGENERICPERSON (Z_PK INTEGER PRIMARY KEY, ZFULLNAME TEXT, ZMERGEDINTO INTEGER)" 234 + ) 235 + conn.execute( 236 + "CREATE TABLE ZGENERICASSET (Z_PK INTEGER PRIMARY KEY, ZDATECREATED REAL)" 237 + ) 238 + conn.execute( 239 + "CREATE TABLE ZDETECTEDFACE (Z_PK INTEGER PRIMARY KEY, ZPERSON INTEGER, ZASSET INTEGER)" 240 + ) 241 + conn.execute( 242 + "INSERT INTO ZGENERICPERSON (Z_PK, ZFULLNAME, ZMERGEDINTO) VALUES (1, 'Alice Johnson', NULL)" 243 + ) 244 + conn.execute( 245 + "INSERT INTO ZGENERICASSET (Z_PK, ZDATECREATED) VALUES (1, 730000000)" 246 + ) 247 + conn.execute( 248 + "INSERT INTO ZDETECTEDFACE (Z_PK, ZPERSON, ZASSET) VALUES (1, 1, 1)" 249 + ) 250 + conn.commit() 251 + finally: 252 + conn.close() 253 + _create_journal( 254 + journal_dir, 255 + [{"id": "alice_johnson", "name": "Alice Johnson", "type": "Person"}], 256 + ) 257 + 258 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_dir)) 259 + monkeypatch.setattr(sys, "platform", "darwin") 260 + 261 + result = runner.invoke(call_app, ["photos", "sync", "--library", str(photos_db)]) 262 + assert result.exit_code == 0 263 + assert "Found 1 named face clusters." in result.output
+43 -2
think/indexer/journal.py
··· 2312 2312 recency: float, 2313 2313 observation_depth: int, 2314 2314 facet_breadth: int, 2315 + photo_count: int = 0, 2315 2316 ) -> float: 2316 2317 """Compute composite strength score from components. 2317 2318 2318 - Weights: kg_edge=5, co_occurrence=4, recency=3, observation_depth=2, facet_breadth=1 2319 + Weights: kg_edge=5, co_occurrence=4, photo_count=3 (log2), recency=3, observation_depth=2, facet_breadth=1 2319 2320 """ 2320 2321 return round( 2321 2322 5 * kg_edge_count 2322 2323 + 4 * co_occurrence 2324 + + 3 * math.log2(photo_count + 1) 2323 2325 + 3 * recency 2324 2326 + 2 * observation_depth 2325 2327 + 1 * facet_breadth, ··· 2362 2364 "facet_breadth": facet_breadth, 2363 2365 "co_occurrence": 0, 2364 2366 "kg_edge_count": 0, 2367 + "photo_count": 0, 2365 2368 } 2366 2369 2367 2370 # Count distinct KG relationship edges per entity (both source and target roles). ··· 2427 2430 if entity_name in scores: 2428 2431 scores[entity_name]["co_occurrence"] = co_count 2429 2432 2433 + photo_where_parts: list[str] = ["signal_type='photo_cooccurrence'"] 2434 + photo_params: list[Any] = [] 2435 + if facet: 2436 + photo_where_parts.append("facet=?") 2437 + photo_params.append(facet.lower()) 2438 + if since: 2439 + photo_where_parts.append("day>=?") 2440 + photo_params.append(since) 2441 + photo_where = " AND ".join(photo_where_parts) 2442 + photo_rows = conn.execute( 2443 + f""" 2444 + SELECT entity_name, COUNT(*) as photo_count 2445 + FROM entity_signals 2446 + WHERE {photo_where} 2447 + GROUP BY entity_name 2448 + """, 2449 + photo_params, 2450 + ).fetchall() 2451 + for entity_name, photo_count in photo_rows: 2452 + if entity_name in scores: 2453 + scores[entity_name]["photo_count"] = photo_count 2454 + 2430 2455 obs_rows = conn.execute( 2431 2456 "SELECT entity_id, observation_count FROM entities WHERE source='observation'" 2432 2457 ).fetchall() ··· 2466 2491 "recency": 0.0, 2467 2492 "facet_breadth": 0, 2468 2493 "observation_depth": 0, 2494 + "photo_count": 0, 2469 2495 } 2470 2496 2471 2497 r = results[key] ··· 2479 2505 r["facet_breadth"] = max( 2480 2506 r["facet_breadth"], components.get("facet_breadth", 0) 2481 2507 ) 2508 + r["photo_count"] = max(r["photo_count"], components.get("photo_count", 0)) 2482 2509 last_day = components.get("last_day", "") 2483 2510 if last_day and last_day > r.get("_last_day", ""): 2484 2511 r["_last_day"] = last_day ··· 2504 2531 r["recency"], 2505 2532 r["observation_depth"], 2506 2533 r["facet_breadth"], 2534 + photo_count=r["photo_count"], 2507 2535 ) 2508 2536 2509 2537 ranked = sorted(results.values(), key=lambda x: x["score"], reverse=True) ··· 2823 2851 max_co = 0 2824 2852 max_breadth = 0 2825 2853 best_last_day = "" 2854 + photo_count = 0 2826 2855 2827 2856 if signal_names: 2828 2857 placeholders_s = ",".join("?" for _ in signal_names) ··· 2843 2872 total_appearance = stat_row[0] 2844 2873 best_last_day = stat_row[1] or "" 2845 2874 max_breadth = stat_row[2] 2875 + 2876 + photo_row = conn.execute( 2877 + f"SELECT COUNT(*) FROM entity_signals WHERE signal_type='photo_cooccurrence' AND entity_name IN ({placeholders_s}){facet_filter}", 2878 + signal_names + facet_params, 2879 + ).fetchone() 2880 + photo_count = photo_row[0] if photo_row else 0 2846 2881 2847 2882 # KG edge count for this entity 2848 2883 kg_facet_filter = " AND facet=?" if facet else "" ··· 2887 2922 2888 2923 strength = { 2889 2924 "score": _strength_score( 2890 - max_kg_edges, max_co, recency, obs_depth, max_breadth 2925 + max_kg_edges, 2926 + max_co, 2927 + recency, 2928 + obs_depth, 2929 + max_breadth, 2930 + photo_count=photo_count, 2891 2931 ), 2892 2932 "kg_edge_count": max_kg_edges, 2893 2933 "co_occurrence": max_co, ··· 2895 2935 "recency": recency, 2896 2936 "facet_breadth": max_breadth, 2897 2937 "observation_depth": obs_depth, 2938 + "photo_count": photo_count, 2898 2939 } 2899 2940 2900 2941 return {