personal memory agent
0
fork

Configure Feed

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

Add journal-level action logging for facet-less operations

Extend the action logging system to support operations not tied to a
specific facet (settings changes, remote observer management, etc.).

- Make facet parameter optional in log_app_action() and log_tool_action()
- Write journal-level logs to config/actions/{day}.jsonl when facet=None
- Add formatter pattern and path metadata extraction for config/actions/
- Add GET /app/settings/api/logs endpoint for journal-level logs
- Log identity updates in settings app and observer create/revoke in remote
- Reorganize JOURNAL.md Action Logs section for clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+360 -62
+61 -14
apps/remote/routes.py
··· 23 23 from flask import Blueprint, jsonify, request 24 24 from werkzeug.utils import secure_filename 25 25 26 - from apps.utils import get_app_storage_path 26 + from apps.utils import get_app_storage_path, log_app_action 27 27 from convey import emit 28 28 from think.utils import day_path 29 29 ··· 82 82 return False 83 83 84 84 85 - def _delete_remote(key: str) -> bool: 86 - """Delete remote by key.""" 87 - remotes_dir = _get_remotes_dir() 88 - remote_path = remotes_dir / f"{key[:8]}.json" 89 - try: 90 - if remote_path.exists(): 91 - remote_path.unlink() 92 - return True 85 + def _revoke_remote(key: str) -> bool: 86 + """Revoke remote by key (soft-delete).""" 87 + remote = _load_remote(key) 88 + if not remote: 93 89 return False 94 - except OSError: 95 - return False 90 + remote["revoked"] = True 91 + remote["revoked_at"] = int(time.time() * 1000) 92 + return _save_remote(remote) 96 93 97 94 98 95 def _list_remotes() -> list[dict]: ··· 129 126 "last_seen": r.get("last_seen"), 130 127 "last_segment": r.get("last_segment"), 131 128 "enabled": r.get("enabled", True), 129 + "revoked": r.get("revoked", False), 130 + "revoked_at": r.get("revoked_at"), 132 131 "stats": r.get("stats", {}), 133 132 } 134 133 ) ··· 162 161 163 162 if not _save_remote(remote_data): 164 163 return jsonify({"error": "Failed to save remote"}), 500 164 + 165 + # Log observer creation (journal-level, no facet) 166 + log_app_action( 167 + app="remote", 168 + facet=None, 169 + action="observer_create", 170 + params={"name": name, "key_prefix": key[:8]}, 171 + ) 165 172 166 173 # Build ingest URL 167 174 ingest_url = f"/app/remote/ingest/{key}" ··· 178 185 179 186 @remote_bp.route("/api/<key_prefix>", methods=["DELETE"]) 180 187 def api_delete(key_prefix: str) -> Any: 181 - """Delete/revoke a remote by key prefix.""" 188 + """Revoke a remote by key prefix (soft-delete).""" 182 189 # Find remote by prefix 183 190 remotes_dir = _get_remotes_dir() 184 191 remote_path = remotes_dir / f"{key_prefix}.json" ··· 189 196 with open(remote_path) as f: 190 197 data = json.load(f) 191 198 key = data.get("key", "") 199 + name = data.get("name", "") 192 200 except (json.JSONDecodeError, OSError): 193 201 return jsonify({"error": "Failed to read remote"}), 500 194 202 195 - if not _delete_remote(key): 196 - return jsonify({"error": "Failed to delete remote"}), 500 203 + if not _revoke_remote(key): 204 + return jsonify({"error": "Failed to revoke remote"}), 500 205 + 206 + # Log observer revocation (journal-level, no facet) 207 + log_app_action( 208 + app="remote", 209 + facet=None, 210 + action="observer_revoke", 211 + params={"name": name, "key_prefix": key_prefix}, 212 + ) 197 213 198 214 return jsonify({"status": "ok"}) 199 215 200 216 217 + @remote_bp.route("/api/<key_prefix>/key") 218 + def api_get_key(key_prefix: str) -> Any: 219 + """Get full key and ingest URL for a remote.""" 220 + # Find remote by prefix 221 + remotes_dir = _get_remotes_dir() 222 + remote_path = remotes_dir / f"{key_prefix}.json" 223 + if not remote_path.exists(): 224 + return jsonify({"error": "Remote not found"}), 404 225 + 226 + try: 227 + with open(remote_path) as f: 228 + data = json.load(f) 229 + except (json.JSONDecodeError, OSError): 230 + return jsonify({"error": "Failed to read remote"}), 500 231 + 232 + key = data.get("key", "") 233 + return jsonify( 234 + { 235 + "key": key, 236 + "name": data.get("name", ""), 237 + "ingest_url": f"/app/remote/ingest/{key}", 238 + } 239 + ) 240 + 241 + 201 242 # === Ingest API (key-protected) === 202 243 203 244 ··· 216 257 remote = _load_remote(key) 217 258 if not remote: 218 259 return jsonify({"error": "Invalid key"}), 401 260 + 261 + if remote.get("revoked", False): 262 + return jsonify({"error": "Remote revoked"}), 403 219 263 220 264 if not remote.get("enabled", True): 221 265 return jsonify({"error": "Remote disabled"}), 403 ··· 327 371 remote = _load_remote(key) 328 372 if not remote: 329 373 return jsonify({"error": "Invalid key"}), 401 374 + 375 + if remote.get("revoked", False): 376 + return jsonify({"error": "Remote revoked"}), 403 330 377 331 378 if not remote.get("enabled", True): 332 379 return jsonify({"error": "Remote disabled"}), 403
+60 -13
apps/settings/routes.py
··· 46 46 47 47 config_path = config_dir / "journal.json" 48 48 49 - # Load existing config using shared utility 49 + # Load existing config twice - once for comparison, once for modification 50 + old_config = get_journal_config() 50 51 config = get_journal_config() 51 52 53 + # Track changes for logging 54 + changed_fields = {} 55 + 52 56 # Update the identity section with provided data 53 57 if "identity" in data: 58 + old_identity = old_config.get("identity", {}) 59 + for key, value in data["identity"].items(): 60 + if old_identity.get(key) != value: 61 + changed_fields[key] = {"old": old_identity.get(key), "new": value} 54 62 config["identity"].update(data["identity"]) 55 63 56 64 # Write back to file 57 65 with open(config_path, "w", encoding="utf-8") as f: 58 66 json.dump(config, f, indent=2, ensure_ascii=False) 59 67 f.write("\n") 68 + 69 + # Log if something changed (journal-level, no facet) 70 + if changed_fields: 71 + log_app_action( 72 + app="settings", 73 + facet=None, 74 + action="identity_update", 75 + params={"changed_fields": changed_fields}, 76 + ) 60 77 61 78 return jsonify({"success": True, "config": config}) 62 79 except RuntimeError as e: ··· 203 220 return jsonify({"error": str(e)}), 500 204 221 205 222 206 - @settings_bp.route("/api/facet/<facet_name>/logs") 207 - def get_facet_logs(facet_name: str) -> Any: 208 - """Get action logs for a facet, one day at a time. 223 + def _get_logs_from_dir(logs_dir: Path, cursor: str | None) -> dict: 224 + """Load action logs from a directory, one day at a time. 209 225 210 - Query params: 226 + Args: 227 + logs_dir: Path to logs directory containing YYYYMMDD.jsonl files 211 228 cursor: Optional YYYYMMDD - load the day before this date 212 229 213 230 Returns: 214 - {day, entries, next_cursor} where next_cursor is null if no more days 231 + Dict with {day, entries, next_cursor} 215 232 """ 216 - logs_dir = Path(state.journal_root) / "facets" / facet_name / "logs" 217 - 218 233 if not logs_dir.exists(): 219 - return jsonify({"day": None, "entries": [], "next_cursor": None}) 234 + return {"day": None, "entries": [], "next_cursor": None} 220 235 221 236 # Find all log files sorted newest first 222 237 log_files = sorted( ··· 226 241 ) 227 242 228 243 if not log_files: 229 - return jsonify({"day": None, "entries": [], "next_cursor": None}) 244 + return {"day": None, "entries": [], "next_cursor": None} 230 245 231 246 # Apply cursor filter if provided 232 - cursor = request.args.get("cursor") 233 247 if cursor: 234 248 log_files = [f for f in log_files if f.stem < cursor] 235 249 236 250 if not log_files: 237 - return jsonify({"day": None, "entries": [], "next_cursor": None}) 251 + return {"day": None, "entries": [], "next_cursor": None} 238 252 239 253 # Load the first (newest) day 240 254 target_file = log_files[0] ··· 256 270 # Determine next cursor 257 271 next_cursor = log_files[1].stem if len(log_files) > 1 else None 258 272 259 - return jsonify({"day": day, "entries": entries, "next_cursor": next_cursor}) 273 + return {"day": day, "entries": entries, "next_cursor": next_cursor} 274 + 275 + 276 + @settings_bp.route("/api/logs") 277 + def get_journal_logs() -> Any: 278 + """Get journal-level action logs, one day at a time. 279 + 280 + These are actions not tied to a specific facet, such as settings changes, 281 + remote observer management, and other journal-wide operations. 282 + 283 + Query params: 284 + cursor: Optional YYYYMMDD - load the day before this date 285 + 286 + Returns: 287 + {day, entries, next_cursor} where next_cursor is null if no more days 288 + """ 289 + logs_dir = Path(state.journal_root) / "config" / "actions" 290 + cursor = request.args.get("cursor") 291 + return jsonify(_get_logs_from_dir(logs_dir, cursor)) 292 + 293 + 294 + @settings_bp.route("/api/facet/<facet_name>/logs") 295 + def get_facet_logs(facet_name: str) -> Any: 296 + """Get action logs for a facet, one day at a time. 297 + 298 + Query params: 299 + cursor: Optional YYYYMMDD - load the day before this date 300 + 301 + Returns: 302 + {day, entries, next_cursor} where next_cursor is null if no more days 303 + """ 304 + logs_dir = Path(state.journal_root) / "facets" / facet_name / "logs" 305 + cursor = request.args.get("cursor") 306 + return jsonify(_get_logs_from_dir(logs_dir, cursor))
+17 -5
apps/utils.py
··· 100 100 101 101 def log_app_action( 102 102 app: str, 103 - facet: str, 103 + facet: str | None, 104 104 action: str, 105 105 params: dict[str, Any], 106 106 day: str | None = None, 107 107 ) -> None: 108 108 """Log a user-initiated action from a Convey app. 109 109 110 - Creates a JSONL log entry in facets/{facet}/logs/{day}.jsonl for tracking 111 - user actions made through the web UI. 110 + Creates a JSONL log entry for tracking user actions made through the web UI. 111 + 112 + When facet is provided, writes to facets/{facet}/logs/{day}.jsonl. 113 + When facet is None, writes to config/actions/{day}.jsonl for journal-level 114 + actions (settings changes, remote observer management, etc.). 112 115 113 116 Args: 114 117 app: App name where action originated (e.g., "entities", "todos") 115 - facet: Facet where action occurred 118 + facet: Facet where action occurred, or None for journal-level actions 116 119 action: Action type (e.g., "entity_add", "todo_complete") 117 120 params: Action-specific parameters to record 118 121 day: Day in YYYYMMDD format (defaults to today) 119 122 120 - Example: 123 + Examples: 124 + # Facet-scoped action 121 125 log_app_action( 122 126 app="entities", 123 127 facet="work", 124 128 action="entity_add", 125 129 params={"type": "Person", "name": "Alice"}, 130 + ) 131 + 132 + # Journal-level action (no facet) 133 + log_app_action( 134 + app="remote", 135 + facet=None, 136 + action="observer_create", 137 + params={"name": "laptop"}, 126 138 ) 127 139 """ 128 140 from think.facets import _write_action_log
+12 -1
docs/APPS.md
··· 366 366 367 367 - `log_app_action(app, facet, action, params, day=None)` - Log user-initiated action 368 368 369 - Use `{domain}_{verb}` naming for actions (e.g., `entity_add`, `todo_complete`). Log after successful mutations, not attempts. 369 + **Parameters:** 370 + - `app` - App name where action originated 371 + - `facet` - Facet where action occurred, or `None` for journal-level actions 372 + - `action` - Action type using `{domain}_{verb}` naming (e.g., `entity_add`, `todo_complete`) 373 + - `params` - Action-specific parameters dict 374 + - `day` - Optional day in YYYYMMDD format (defaults to today) 375 + 376 + **Facet-scoped vs journal-level:** 377 + - Pass a facet name for facet-specific actions (todos, entities, etc.) 378 + - Pass `facet=None` for journal-level actions (settings, remote observers, etc.) 379 + 380 + Log after successful mutations, not attempts. 370 381 371 382 --- 372 383
+35 -10
docs/JOURNAL.md
··· 60 60 - `task_log.txt` – optional log of utility runs in `[epoch]\tmessage` format. 61 61 - `config/journal.json` – user configuration for the journal (optional, see below). 62 62 - `config/convey.json` – Convey UI preferences (facet/app ordering, selected facet). 63 + - `config/actions/` – journal-level action logs (see below). 63 64 - `facets/` – facet-specific organization folders described below. 64 65 - `tokens/` – token usage logs from AI model calls, organized by day (see below). 65 66 - `apps/` – app-specific storage for configuration and data (distinct from codebase `apps/`, see below). ··· 349 350 350 351 This facet-scoped structure provides true separation of concerns while enabling automated tools to manage tasks deterministically. 351 352 352 - ## Facet Action Logs 353 + ## Action Logs 353 354 354 - The `logs/` directory within each facet records an audit trail of MCP tool calls and actions. Logs are organized by day as `facets/{facet}/logs/YYYYMMDD.jsonl`. 355 + Action logs record an audit trail of user-initiated actions and MCP tool calls. There are two types: 356 + 357 + - **Journal-level logs** (`config/actions/`) – actions not tied to a specific facet (settings changes, remote observer management) 358 + - **Facet-scoped logs** (`facets/{facet}/logs/`) – actions within a specific facet (todos, entities) 355 359 356 - ### Log entry format 360 + ### Journal Action Logs 357 361 358 - Each line is a JSON object recording an action: 362 + The `config/actions/` directory records journal-level actions. Logs are organized by day as `config/actions/YYYYMMDD.jsonl`. 363 + 364 + ```json 365 + { 366 + "timestamp": "2025-12-16T07:33:05.135587+00:00", 367 + "source": "app", 368 + "actor": "settings", 369 + "action": "identity_update", 370 + "params": { 371 + "changed_fields": {"name": {"old": "John", "new": "John Doe"}} 372 + } 373 + } 374 + ``` 375 + 376 + ### Facet Action Logs 377 + 378 + The `logs/` directory within each facet records facet-scoped actions. Logs are organized by day as `facets/{facet}/logs/YYYYMMDD.jsonl`. 359 379 360 380 ```json 361 381 { ··· 367 387 "line_number": 1, 368 388 "text": "Review project proposal" 369 389 }, 390 + "facet": "work", 370 391 "agent_id": "1765870373972" 371 392 } 372 393 ``` 373 394 374 - Fields: 395 + ### Log Entry Fields 396 + 397 + Both log types share the same structure: 398 + 375 399 - `timestamp` – ISO 8601 timestamp of the action 376 - - `source` – Origin type (e.g., "tool" for MCP tool calls) 377 - - `actor` – Tool or component that performed the action (e.g., "todos:todo", "mcp") 378 - - `action` – Action name (e.g., "todo_add", "entity_add_aka") 379 - - `params` – Parameters passed to the action 380 - - `agent_id` – Optional agent ID if action was triggered by an agent 400 + - `source` – Origin type: "app" for web UI, "tool" for MCP tools 401 + - `actor` – App or tool name that performed the action 402 + - `action` – Action name (e.g., "todo_add", "identity_update") 403 + - `params` – Action-specific parameters 404 + - `facet` – Facet name (only present in facet-scoped logs) 405 + - `agent_id` – Agent ID (only present for MCP tool actions) 381 406 382 407 These logs enable auditing, debugging, and potential rollback of automated actions. 383 408
+79
tests/test_action_logging.py
··· 10 10 import pytest 11 11 12 12 from apps.entities.tools import entity_attach, entity_detect 13 + from apps.utils import log_app_action 13 14 14 15 15 16 @pytest.fixture ··· 33 34 def read_log_entries(journal_path: Path, facet: str, day: str) -> list[dict]: 34 35 """Read all log entries from a facet's log file.""" 35 36 log_path = journal_path / "facets" / facet / "logs" / f"{day}.jsonl" 37 + if not log_path.exists(): 38 + return [] 39 + 40 + entries = [] 41 + with open(log_path, "r", encoding="utf-8") as f: 42 + for line in f: 43 + if line.strip(): 44 + entries.append(json.loads(line)) 45 + return entries 46 + 47 + 48 + def read_journal_log_entries(journal_path: Path, day: str) -> list[dict]: 49 + """Read all log entries from the journal-level log file.""" 50 + log_path = journal_path / "config" / "actions" / f"{day}.jsonl" 36 51 if not log_path.exists(): 37 52 return [] 38 53 ··· 78 93 assert entries[0]["params"]["type"] == "Company" 79 94 assert entries[0]["params"]["name"] == "Acme Corp" 80 95 assert entries[0]["params"]["description"] == "Test company" 96 + 97 + 98 + def test_journal_level_logging(test_facet): 99 + """Test that log_app_action with facet=None writes to config/actions/.""" 100 + journal, _ = test_facet 101 + today = datetime.now().strftime("%Y%m%d") 102 + 103 + # Log a journal-level action (no facet) 104 + log_app_action( 105 + app="settings", 106 + facet=None, 107 + action="identity_update", 108 + params={"name": "Test User"}, 109 + ) 110 + 111 + # Check log entry was created in config/actions/ 112 + entries = read_journal_log_entries(journal, today) 113 + assert len(entries) == 1 114 + assert entries[0]["action"] == "identity_update" 115 + assert entries[0]["source"] == "app" 116 + assert entries[0]["actor"] == "settings" 117 + assert entries[0]["params"]["name"] == "Test User" 118 + # Should NOT have a facet field 119 + assert "facet" not in entries[0] 120 + 121 + 122 + def test_journal_level_log_directory_created(test_facet): 123 + """Test that config/actions/ directory is created automatically.""" 124 + journal, _ = test_facet 125 + 126 + # Verify config/actions doesn't exist yet 127 + actions_dir = journal / "config" / "actions" 128 + assert not actions_dir.exists() 129 + 130 + # Log a journal-level action 131 + log_app_action( 132 + app="remote", 133 + facet=None, 134 + action="observer_create", 135 + params={"name": "test-observer"}, 136 + ) 137 + 138 + # Verify directory was created 139 + assert actions_dir.exists() 140 + assert actions_dir.is_dir() 141 + 142 + 143 + def test_facet_action_includes_facet_field(test_facet): 144 + """Test that facet-scoped actions include the facet field in the entry.""" 145 + journal, facet = test_facet 146 + today = datetime.now().strftime("%Y%m%d") 147 + 148 + # Log a facet-scoped action 149 + log_app_action( 150 + app="todos", 151 + facet=facet, 152 + action="todo_add", 153 + params={"text": "Test todo"}, 154 + ) 155 + 156 + # Check that the facet field is included 157 + entries = read_log_entries(journal, facet, today) 158 + assert len(entries) == 1 159 + assert entries[0]["facet"] == facet
+40
tests/test_formatters.py
··· 1475 1475 assert meta["facet"] == "" 1476 1476 assert meta["topic"] == "myapp:custom" 1477 1477 1478 + def test_config_actions(self): 1479 + """Test journal-level action log path extraction.""" 1480 + from think.formatters import extract_path_metadata 1481 + 1482 + meta = extract_path_metadata("config/actions/20240101.jsonl") 1483 + assert meta["day"] == "20240101" 1484 + assert meta["facet"] == "" 1485 + assert meta["topic"] == "" 1486 + 1478 1487 1479 1488 class TestFormatterIndexerMetadata: 1480 1489 """Tests verifying formatters return indexer metadata.""" ··· 1896 1905 assert len(chunks) == 1 1897 1906 # "entity_update_description" should become "Entity Update Description" 1898 1907 assert "Entity Update Description by mcp" in chunks[0]["markdown"] 1908 + 1909 + def test_get_formatter_journal_level_logs(self): 1910 + """Test pattern matching for config/actions/*.jsonl.""" 1911 + from think.formatters import get_formatter 1912 + 1913 + formatter = get_formatter("config/actions/20240101.jsonl") 1914 + assert formatter is not None 1915 + assert formatter.__name__ == "format_logs" 1916 + 1917 + def test_format_logs_journal_level_header(self): 1918 + """Test that journal-level logs have appropriate header.""" 1919 + from think.facets import format_logs 1920 + 1921 + entries = [ 1922 + { 1923 + "timestamp": "2025-12-16T07:33:05.135587+00:00", 1924 + "source": "app", 1925 + "actor": "settings", 1926 + "action": "identity_update", 1927 + "params": {"name": "Test User"}, 1928 + } 1929 + ] 1930 + context = {"file_path": "/journal/config/actions/20251216.jsonl"} 1931 + 1932 + chunks, meta = format_logs(entries, context) 1933 + 1934 + assert "header" in meta 1935 + assert "Journal Action Log" in meta["header"] 1936 + assert "2025-12-16" in meta["header"] 1937 + # Should NOT contain a facet name 1938 + assert ":" not in meta["header"] or "Journal" in meta["header"]
+50 -19
think/facets.py
··· 62 62 63 63 64 64 def _write_action_log( 65 - facet: str, 65 + facet: str | None, 66 66 action: str, 67 67 params: dict[str, Any], 68 68 source: str, ··· 70 70 day: str | None = None, 71 71 agent_id: str | None = None, 72 72 ) -> None: 73 - """Write action to the facet's daily audit log. 73 + """Write action to the daily audit log. 74 + 75 + Internal function that writes JSONL log entries. When facet is provided, 76 + writes to facets/{facet}/logs/{day}.jsonl. When facet is None, writes to 77 + config/actions/{day}.jsonl for journal-level actions. 74 78 75 - Internal function that writes JSONL log entries to facets/{facet}/logs/{day}.jsonl. 76 79 Use log_tool_action() for MCP tools or log_app_action() for web apps. 77 80 78 81 Args: 79 - facet: Facet name where the action occurred 82 + facet: Facet name where the action occurred, or None for journal-level 80 83 action: Action type (e.g., "todo_add", "entity_attach") 81 84 params: Dictionary of action-specific parameters 82 85 source: Origin type - "tool" for MCP agents, "app" for web UI ··· 95 98 if day is None: 96 99 day = datetime.now().strftime("%Y%m%d") 97 100 98 - # Build log file path 99 - log_path = Path(journal) / "facets" / facet / "logs" / f"{day}.jsonl" 101 + # Build log file path based on whether facet is provided 102 + if facet is not None: 103 + log_path = Path(journal) / "facets" / facet / "logs" / f"{day}.jsonl" 104 + else: 105 + log_path = Path(journal) / "config" / "actions" / f"{day}.jsonl" 100 106 101 107 # Ensure parent directory exists 102 108 log_path.parent.mkdir(parents=True, exist_ok=True) ··· 110 116 "params": params, 111 117 } 112 118 119 + # Add facet only if provided 120 + if facet is not None: 121 + entry["facet"] = facet 122 + 113 123 # Add agent_id only if available 114 124 if agent_id is not None: 115 125 entry["agent_id"] = agent_id ··· 120 130 121 131 122 132 def log_tool_action( 123 - facet: str, 133 + facet: str | None, 124 134 action: str, 125 135 params: dict[str, Any], 126 136 context: Context | None = None, ··· 128 138 ) -> None: 129 139 """Log an agent-initiated action from an MCP tool. 130 140 131 - Creates a JSONL log entry in facets/{facet}/logs/{day}.jsonl for tracking 132 - successful modifications made via MCP tools. Automatically extracts actor 133 - identity (persona) from FastMCP context. 141 + Creates a JSONL log entry for tracking successful modifications made via 142 + MCP tools. Automatically extracts actor identity (persona) from FastMCP 143 + context. 144 + 145 + When facet is provided, writes to facets/{facet}/logs/{day}.jsonl. 146 + When facet is None, writes to config/actions/{day}.jsonl for journal-level 147 + actions (settings changes, system operations, etc.). 134 148 135 149 Args: 136 - facet: Facet name where the action occurred 150 + facet: Facet name where the action occurred, or None for journal-level 137 151 action: Action type (e.g., "todo_add", "entity_attach") 138 152 params: Dictionary of action-specific parameters 139 153 context: Optional FastMCP context for extracting persona/agent_id ··· 603 617 """Format action log JSONL entries to markdown chunks. 604 618 605 619 This is the formatter function used by the formatters registry. 620 + Handles both facet-scoped logs (facets/{facet}/logs/) and journal-level 621 + logs (config/actions/). 606 622 607 623 Args: 608 624 entries: Raw JSONL entries (one action log per line) ··· 624 640 skipped_count = 0 625 641 626 642 # Extract facet name and day from path 627 - facet_name = "unknown" 643 + facet_name: str | None = None 628 644 day_str: str | None = None 645 + is_journal_level = False 629 646 630 647 if file_path: 631 648 file_path = Path(file_path) 649 + path_str = str(file_path) 632 650 633 - # Extract facet name from path: facets/{facet}/logs/YYYYMMDD.jsonl 634 - path_str = str(file_path) 635 - facet_match = re.search(r"facets/([^/]+)/logs", path_str) 636 - if facet_match: 637 - facet_name = facet_match.group(1) 651 + # Check for journal-level logs: config/actions/YYYYMMDD.jsonl 652 + if "config/actions" in path_str or "config\\actions" in path_str: 653 + is_journal_level = True 654 + else: 655 + # Extract facet name from path: facets/{facet}/logs/YYYYMMDD.jsonl 656 + facet_match = re.search(r"facets/([^/]+)/logs", path_str) 657 + if facet_match: 658 + facet_name = facet_match.group(1) 638 659 639 660 # Extract day from filename 640 661 if file_path.stem.isdigit() and len(file_path.stem) == 8: ··· 643 664 # Build header 644 665 if day_str: 645 666 formatted_day = f"{day_str[:4]}-{day_str[4:6]}-{day_str[6:8]}" 646 - meta["header"] = f"# Action Log: {facet_name} ({formatted_day})" 667 + if is_journal_level: 668 + meta["header"] = f"# Journal Action Log ({formatted_day})" 669 + elif facet_name: 670 + meta["header"] = f"# Action Log: {facet_name} ({formatted_day})" 671 + else: 672 + meta["header"] = f"# Action Log ({formatted_day})" 647 673 else: 648 - meta["header"] = f"# Action Log: {facet_name}" 674 + if is_journal_level: 675 + meta["header"] = "# Journal Action Log" 676 + elif facet_name: 677 + meta["header"] = f"# Action Log: {facet_name}" 678 + else: 679 + meta["header"] = "# Action Log" 649 680 650 681 # Format each log entry as a chunk 651 682 for entry in entries:
+6
think/formatters.py
··· 92 92 import_id = parts[1] 93 93 day = import_id.split("_")[0] if "_" in import_id else import_id[:8] 94 94 95 + # Extract day from config/actions/YYYYMMDD.jsonl (journal-level logs) 96 + if parts[0] == "config" and len(parts) >= 3 and parts[1] == "actions": 97 + if _DATE_RE.match(basename): 98 + day = basename 99 + 95 100 # Derive topic for markdown files only 96 101 if is_markdown: 97 102 if parts[0] == "facets" and len(parts) >= 4 and parts[2] == "news": ··· 114 119 FORMATTERS: dict[str, tuple[str, str]] = { 115 120 # JSONL formatters 116 121 "agents/*.jsonl": ("muse.cortex", "format_agent"), 122 + "config/actions/*.jsonl": ("think.facets", "format_logs"), 117 123 "facets/*/entities/*.jsonl": ("think.entities", "format_entities"), 118 124 "facets/*/entities.jsonl": ("think.entities", "format_entities"), 119 125 "facets/*/events/*.jsonl": ("think.events", "format_events"),