interlatch 0.22.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.
- chronicle/__init__.py +17 -0
- chronicle/__main__.py +3 -0
- chronicle/access.py +93 -0
- chronicle/agents.py +23 -0
- chronicle/analyze.py +498 -0
- chronicle/antigravity_parser.py +311 -0
- chronicle/artifacts.py +621 -0
- chronicle/bob_parser.py +259 -0
- chronicle/cases.py +75 -0
- chronicle/chat_import.py +325 -0
- chronicle/chatgpt_export.py +127 -0
- chronicle/claude_export.py +129 -0
- chronicle/cli.py +2980 -0
- chronicle/codex_cloud.py +138 -0
- chronicle/codex_parser.py +753 -0
- chronicle/config.py +627 -0
- chronicle/connectors.py +666 -0
- chronicle/container.py +202 -0
- chronicle/copilot_parser.py +494 -0
- chronicle/db.py +598 -0
- chronicle/desktop.py +543 -0
- chronicle/diagram.py +488 -0
- chronicle/digest.py +194 -0
- chronicle/export_md.py +210 -0
- chronicle/friction.py +841 -0
- chronicle/glossary.py +522 -0
- chronicle/groups.py +181 -0
- chronicle/hooks.py +169 -0
- chronicle/hub.py +2291 -0
- chronicle/i18n.py +37 -0
- chronicle/i18n_ja.py +421 -0
- chronicle/ingest.py +1190 -0
- chronicle/install.py +590 -0
- chronicle/instructions.py +699 -0
- chronicle/ladder.py +244 -0
- chronicle/learning.py +170 -0
- chronicle/llm.py +570 -0
- chronicle/mcp_server.py +403 -0
- chronicle/menubar.py +771 -0
- chronicle/migrate.py +580 -0
- chronicle/mirror.py +453 -0
- chronicle/news.py +110 -0
- chronicle/notify.py +92 -0
- chronicle/parser.py +908 -0
- chronicle/people.py +536 -0
- chronicle/pg.py +122 -0
- chronicle/pricing.py +168 -0
- chronicle/providers.py +591 -0
- chronicle/redact.py +50 -0
- chronicle/reviews.py +251 -0
- chronicle/screen.py +308 -0
- chronicle/search.py +238 -0
- chronicle/server.py +3009 -0
- chronicle/session_export.py +178 -0
- chronicle/statusline.py +258 -0
- chronicle/suggest.py +643 -0
- chronicle/synthesize.py +365 -0
- chronicle/systems.py +1265 -0
- chronicle/tailnet.py +97 -0
- chronicle/team_store.py +450 -0
- chronicle/update.py +404 -0
- chronicle/util.py +217 -0
- chronicle/views.py +249 -0
- chronicle/web/PLEX-OFL.txt +93 -0
- chronicle/web/app.css +2350 -0
- chronicle/web/app.js +7587 -0
- chronicle/web/art-book.webp +0 -0
- chronicle/web/art-camera.webp +0 -0
- chronicle/web/art-chip.webp +0 -0
- chronicle/web/art-clock.webp +0 -0
- chronicle/web/art-cloud.webp +0 -0
- chronicle/web/art-coins.webp +0 -0
- chronicle/web/art-container.webp +0 -0
- chronicle/web/art-dashboard.webp +0 -0
- chronicle/web/art-dex.webp +0 -0
- chronicle/web/art-document.webp +0 -0
- chronicle/web/art-flag.webp +0 -0
- chronicle/web/art-folder.webp +0 -0
- chronicle/web/art-gateway.webp +0 -0
- chronicle/web/art-gears.webp +0 -0
- chronicle/web/art-glitch.webp +0 -0
- chronicle/web/art-globe.webp +0 -0
- chronicle/web/art-laptop.webp +0 -0
- chronicle/web/art-log.webp +0 -0
- chronicle/web/art-lumi.webp +0 -0
- chronicle/web/art-magnifier.webp +0 -0
- chronicle/web/art-pip.webp +0 -0
- chronicle/web/art-plug.webp +0 -0
- chronicle/web/art-rack.webp +0 -0
- chronicle/web/art-tink.webp +0 -0
- chronicle/web/boot.js +25 -0
- chronicle/web/favicon.png +0 -0
- chronicle/web/i18n.js +50 -0
- chronicle/web/icon-180.png +0 -0
- chronicle/web/icon-192.png +0 -0
- chronicle/web/icon-512.png +0 -0
- chronicle/web/icon.png +0 -0
- chronicle/web/index.html +92 -0
- chronicle/web/ja.js +1880 -0
- chronicle/web/learning.js +438 -0
- chronicle/web/manifest.webmanifest +15 -0
- chronicle/web/panel.css +175 -0
- chronicle/web/panel.html +14 -0
- chronicle/web/panel.js +241 -0
- chronicle/web/plex-mono-400.woff2 +0 -0
- chronicle/web/plex-mono-500.woff2 +0 -0
- chronicle/web/plex-mono-600.woff2 +0 -0
- chronicle/web/plex-sans-400.woff2 +0 -0
- chronicle/web/plex-sans-500.woff2 +0 -0
- chronicle/web/plex-sans-600.woff2 +0 -0
- chronicle/web/plex-sans-700.woff2 +0 -0
- chronicle/web/rough.js +2 -0
- chronicle/worker.py +390 -0
- interlatch-0.22.0.dist-info/METADATA +221 -0
- interlatch-0.22.0.dist-info/RECORD +118 -0
- interlatch-0.22.0.dist-info/WHEEL +4 -0
- interlatch-0.22.0.dist-info/entry_points.txt +3 -0
- interlatch-0.22.0.dist-info/licenses/LICENSE +21 -0
chronicle/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Interlatch: shared memory for your coding agents, recorded locally from every session (Claude Code, Codex, Copilot,
|
|
2
|
+
Bob, Antigravity), each lesson linked to the session it came from."""
|
|
3
|
+
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _version() -> str:
|
|
8
|
+
"""Single source of truth: pyproject.toml, via the installed package's metadata."""
|
|
9
|
+
for dist in ("interlatch", "agents-chronicle"): # the distribution, and its name before the rename
|
|
10
|
+
try:
|
|
11
|
+
return version(dist)
|
|
12
|
+
except PackageNotFoundError:
|
|
13
|
+
continue
|
|
14
|
+
return "0.0.0+unknown" # running from a source tree that was never installed
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
__version__ = _version()
|
chronicle/__main__.py
ADDED
chronicle/access.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""What a person limited to projects (people.projects_of) may see of a hub's dashboard.
|
|
2
|
+
|
|
3
|
+
Two layers, so one forgotten filter can't show another project:
|
|
4
|
+
|
|
5
|
+
1. Endpoints: only those in LIMITED_GET answer; everything else is refused before it runs (allowed()).
|
|
6
|
+
2. Data: the request's database connection gets TEMP views named like the tables they shadow (SQLite looks in the
|
|
7
|
+
temp schema first), so every query an allowed endpoint makes sees only the person's projects (limit()):
|
|
8
|
+
- sessions: theirs, analyzed, with only the columns a member's computer shares as knowledge (hub.SHARED_COLS,
|
|
9
|
+
the analysis); prompts, paths, commands and prompt-made titles read as NULL
|
|
10
|
+
- knowledge: project lessons from those sessions' analysis (never lessons about a person, never global ones)
|
|
11
|
+
- project_kb: their projects' knowledge bases; api_calls: their sessions' token counts
|
|
12
|
+
- hub_news: what computers sent to their projects (news.py), no computer joining
|
|
13
|
+
- every other table that holds session or project content reads as empty (EMPTY), transcripts above all.
|
|
14
|
+
|
|
15
|
+
The temp views live only on that request's connection, which the server closes when the request ends.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
import sqlite3
|
|
22
|
+
|
|
23
|
+
# GET endpoints a limited person may call; /api/sessions/<id> (the summary page) is matched separately
|
|
24
|
+
LIMITED_GET = frozenset({"/api/me", "/api/overview", "/api/team", "/api/team/who", "/api/team/news", "/api/sessions",
|
|
25
|
+
"/api/projects", "/api/project", "/api/knowledge", "/api/learning/interests", "/api/jobs", "/api/diagram"})
|
|
26
|
+
SESSION_PAGE = re.compile(r"/api/sessions/[\w-]+")
|
|
27
|
+
|
|
28
|
+
# tables whose rows are a session's content or another project's: read as empty
|
|
29
|
+
EMPTY = ("events", "tool_calls", "subagents", "session_files", "artifacts", "analyses", "glossary", "glossary_usage",
|
|
30
|
+
"glossary_themes", "suggestions", "suggestion_scopes", "reviews", "files_state", "project_groups",
|
|
31
|
+
"project_group_picks", "group_shares")
|
|
32
|
+
|
|
33
|
+
# sessions columns a limited person sees; the rest read as NULL
|
|
34
|
+
_ALWAYS = ("id", "source", "agent", "machine_id", "project_path", "project_name", "analysis_status", "llm_title")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def allowed(path: str) -> bool:
|
|
38
|
+
return path in LIMITED_GET or bool(SESSION_PAGE.fullmatch(path))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
|
42
|
+
return [r[1] for r in conn.execute(f"PRAGMA main.table_info({table})")]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def limit(conn: sqlite3.Connection, projects: list[str]) -> None:
|
|
46
|
+
"""Shadow the tables on this connection with views of `projects` only (see the module docstring)."""
|
|
47
|
+
from .hub import ANALYSIS_COLS, SHARED_COLS
|
|
48
|
+
|
|
49
|
+
lit = ", ".join(_quote(p) for p in projects) or "NULL" # views can't take parameters: quoted literals
|
|
50
|
+
visible = set(_ALWAYS) | set(SHARED_COLS) | (set(ANALYSIS_COLS) - {"analysis_json"})
|
|
51
|
+
cols = []
|
|
52
|
+
for c in _columns(conn, "sessions"):
|
|
53
|
+
if c == "title":
|
|
54
|
+
cols.append("llm_title AS title") # the analysis' title; a title made from the first prompt stays hidden
|
|
55
|
+
elif c == "source_present":
|
|
56
|
+
cols.append("0 AS source_present") # no transcript to open
|
|
57
|
+
elif c in visible:
|
|
58
|
+
cols.append(c)
|
|
59
|
+
else:
|
|
60
|
+
cols.append(f"NULL AS {c}")
|
|
61
|
+
for t in ("sessions", "knowledge", "project_kb", "api_calls", "hub_news", *EMPTY): # never a view left from before
|
|
62
|
+
conn.execute(f"DROP VIEW IF EXISTS temp.{t}")
|
|
63
|
+
mine = f"project_path IN ({lit}) AND analysis_status = 'done' AND source NOT IN ('history')"
|
|
64
|
+
conn.execute(f"CREATE TEMP VIEW sessions AS SELECT {', '.join(cols)} FROM main.sessions WHERE {mine}")
|
|
65
|
+
conn.execute("CREATE TEMP VIEW knowledge AS SELECT * FROM main.knowledge WHERE "
|
|
66
|
+
f"project_path IN ({lit}) AND source = 'analysis' AND scope = 'project' AND kind != 'preference' "
|
|
67
|
+
f"AND session_id IN (SELECT id FROM main.sessions WHERE {mine})")
|
|
68
|
+
conn.execute(f"CREATE TEMP VIEW project_kb AS SELECT * FROM main.project_kb WHERE project_path IN ({lit})")
|
|
69
|
+
conn.execute("CREATE TEMP VIEW api_calls AS SELECT * FROM main.api_calls WHERE "
|
|
70
|
+
f"session_id IN (SELECT id FROM main.sessions WHERE {mine})")
|
|
71
|
+
conn.execute(f"CREATE TEMP VIEW hub_news AS SELECT * FROM main.hub_news WHERE kind = 'shared' AND project_path IN ({lit})")
|
|
72
|
+
for t in EMPTY:
|
|
73
|
+
if _columns(conn, t):
|
|
74
|
+
conn.execute(f"CREATE TEMP VIEW {t} AS SELECT * FROM main.{t} WHERE 0")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _quote(value: str) -> str:
|
|
78
|
+
return "'" + value.replace("'", "''") + "'"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def session_page(s: dict) -> dict:
|
|
82
|
+
"""A session as a limited person sees it: what a member's computer shares as knowledge, nothing it ran on."""
|
|
83
|
+
keep = {"id", "source", "agent", "title", "project_name", "project_path", "started_at", "ended_at", "duration_s",
|
|
84
|
+
"active_s", "primary_model", "n_prompts", "n_tool_calls", "n_tool_errors", "n_subagents", "n_files",
|
|
85
|
+
"lines_added", "lines_removed", "total_tokens", "est_cost_usd", "outcome", "outcome_note", "sentiment",
|
|
86
|
+
"summary", "goal", "analysis_status", "analyzed_at", "llm_title", "work_types", "tags", "highlights",
|
|
87
|
+
"open_threads", "knowledge", "machine_name"}
|
|
88
|
+
out = {k: v for k, v in s.items() if k in keep}
|
|
89
|
+
out.update(source_present=0, limited=True, prompts=[], files=[], subagents=[], tool_errors=[], markers=[],
|
|
90
|
+
tool_stats=[], api_calls=[], analyses=[], outputs=[], agents=[], prs=[], artifacts=[], commands={},
|
|
91
|
+
branches={}, hooks={}, models={}, tools={}, skills={}, mcp={}, workflows=[], friction=[],
|
|
92
|
+
waiting=None, usage=None)
|
|
93
|
+
return out
|
chronicle/agents.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""The coding agents Interlatch records, and how each is named."""
|
|
2
|
+
|
|
3
|
+
AGENTS = { # id -> (full name, short name, speaker label in digests)
|
|
4
|
+
"claude": ("Claude Code", "Claude", "CLAUDE"),
|
|
5
|
+
"codex": ("OpenAI Codex", "Codex", "CODEX"),
|
|
6
|
+
"copilot": ("GitHub Copilot", "Copilot", "COPILOT"),
|
|
7
|
+
"bob": ("IBM Bob", "Bob", "BOB"),
|
|
8
|
+
"antigravity": ("Google Antigravity", "Antigravity", "ANTIGRAVITY"),
|
|
9
|
+
"claude-ai": ("Claude.ai", "Claude.ai", "CLAUDE"), # chats imported from a claude.ai data export
|
|
10
|
+
"chatgpt": ("ChatGPT", "ChatGPT", "CHATGPT"), # chats imported from a ChatGPT data export
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def full_name(agent: str | None) -> str:
|
|
15
|
+
return AGENTS.get(agent or "claude", (agent or "agent",) * 3)[0]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def short_name(agent: str | None) -> str:
|
|
19
|
+
return AGENTS.get(agent or "claude", (agent or "agent",) * 3)[1]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def speaker(agent: str | None) -> str:
|
|
23
|
+
return AGENTS.get(agent or "claude", ("", "", (agent or "agent").upper()))[2]
|
chronicle/analyze.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Per-session analysis: overview + knowledge extraction by the analysis model (map-reduce for long sessions)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sqlite3
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
from . import ladder, news
|
|
11
|
+
from .cases import LESSON_KINDS, VISUAL_SCHEMA, lesson_of
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .digest import build_digest
|
|
14
|
+
from .ingest import best_title
|
|
15
|
+
from .learning import TOPICS, normalize_interests, store_interests
|
|
16
|
+
from .llm import (BudgetExceededError, LLMError, LLMResult, Runner, SleepInterruptedError, UsageLimitError, make_runner,
|
|
17
|
+
written_in)
|
|
18
|
+
from .redact import redact
|
|
19
|
+
from .util import dumps, fingerprint, to_iso, utcnow, utcnow_iso
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger("chronicle.analyze")
|
|
22
|
+
|
|
23
|
+
PROMPT_VERSION = 3 # 2: fixes, gotchas and decisions carry a case file; 3: lessons carry a principle, checks, topics, a diagram
|
|
24
|
+
|
|
25
|
+
KNOWLEDGE_KINDS = {
|
|
26
|
+
"fix": "a bug or failure with its root cause and the fix",
|
|
27
|
+
"gotcha": "a pitfall or surprising behavior, and how to avoid it",
|
|
28
|
+
"learning": "an insight about a technology, library, API or this codebase",
|
|
29
|
+
"decision": "a design or architecture choice and its rationale",
|
|
30
|
+
"pattern": "a reusable approach, technique or code snippet that worked",
|
|
31
|
+
"command": "a useful command or invocation and when to use it",
|
|
32
|
+
"fact": "a fact about the project: layout, config, endpoints, data, deployment",
|
|
33
|
+
"preference": "how the developer wants the coding agent to work or communicate",
|
|
34
|
+
"reference": "a pointer to an external resource (URL, doc, dashboard, ticket)",
|
|
35
|
+
"todo": "a follow-up task worth tracking",
|
|
36
|
+
}
|
|
37
|
+
WORK_TYPES = [
|
|
38
|
+
"feature", "bugfix", "debugging", "refactor", "research", "exploration", "ops", "deploy", "config",
|
|
39
|
+
"docs", "testing", "review", "planning", "data", "design", "learning", "other",
|
|
40
|
+
]
|
|
41
|
+
OUTCOMES = ["completed", "partial", "blocked", "abandoned", "exploratory", "unclear"]
|
|
42
|
+
FRICTION_KINDS = ["tool_error", "environment", "misunderstanding", "rework", "permissions", "performance", "external", "other"]
|
|
43
|
+
CASE_KINDS = ("fix", "gotcha", "decision") # shown to people as case files: the scene and a question before the answer
|
|
44
|
+
# knowledge.case_json also holds lesson material without a case file (a learning's principle, say): a case file has a question
|
|
45
|
+
CASE_FILE_SQL = "json_extract(case_json, '$.question') IS NOT NULL"
|
|
46
|
+
MAX_LEADS = 4
|
|
47
|
+
|
|
48
|
+
RULED_OUT_SCHEMA = {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"additionalProperties": False,
|
|
51
|
+
"required": ["lead", "why"],
|
|
52
|
+
"properties": {
|
|
53
|
+
"lead": {"type": "string", "description": "The wrong lead, phrased as an answer to the question, at most 12 words"},
|
|
54
|
+
"why": {"type": "string", "description": "What showed it wrong, one sentence"},
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
KNOWLEDGE_ITEM_SCHEMA = {
|
|
58
|
+
"type": "object",
|
|
59
|
+
"additionalProperties": False,
|
|
60
|
+
"required": ["kind", "title", "body", "tags", "scope", "confidence", "evidence", "scene", "question", "answer",
|
|
61
|
+
"ruled_out", "clues", "principle", "checklist", "topics", "visual"],
|
|
62
|
+
"properties": {
|
|
63
|
+
"kind": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"enum": list(KNOWLEDGE_KINDS),
|
|
66
|
+
"description": "; ".join(f"{k}: {v}" for k, v in KNOWLEDGE_KINDS.items()),
|
|
67
|
+
},
|
|
68
|
+
"title": {"type": "string", "description": "Specific headline, at most ~90 characters"},
|
|
69
|
+
"body": {"type": "string", "description": "Self-contained explanation (2-6 sentences, or a short snippet). Markdown allowed."},
|
|
70
|
+
"tags": {"type": "array", "items": {"type": "string"}, "description": "2-5 lowercase tags"},
|
|
71
|
+
"scope": {"type": "string", "enum": ["project", "global"]},
|
|
72
|
+
"confidence": {"type": "string", "enum": ["high", "medium", "low"]},
|
|
73
|
+
"evidence": {"type": "string", "description": "Where the session established this, e.g. 'prompt 4: tests passed after the fix'"},
|
|
74
|
+
"scene": {"type": "string", "description": "fix, gotcha, decision: what was being done and what was seen, before "
|
|
75
|
+
"the answer was known (1-3 sentences, the error verbatim); a decision: the problem it settled. Never "
|
|
76
|
+
"gives the answer away. Empty for other kinds"},
|
|
77
|
+
"question": {"type": "string", "description": "fix, gotcha, decision: the question the scene raises, as the "
|
|
78
|
+
"developer would have asked it then. Empty for other kinds"},
|
|
79
|
+
"answer": {"type": "string", "description": "fix, gotcha, decision: the answer to the question, at most 12 "
|
|
80
|
+
"words (the cause or the call, not the fix). Empty for other kinds"},
|
|
81
|
+
"ruled_out": {"type": "array", "items": RULED_OUT_SCHEMA, "description": "fix, gotcha, decision: leads the "
|
|
82
|
+
"session tried and found wrong (a decision: the options turned down), at most 4. Only dead ends "
|
|
83
|
+
"the transcript shows; empty when there were none or for other kinds"},
|
|
84
|
+
"clues": {"type": "array", "items": {"type": "string"}, "description": "fix, gotcha: up to 6 facts the session "
|
|
85
|
+
"observed before the cause was known (errors, test results), in order. Never invented. Empty otherwise"},
|
|
86
|
+
"principle": {"type": "string", "description": "fix, gotcha, decision, learning, pattern: the general idea this "
|
|
87
|
+
"is an instance of, stated so it applies beyond this project, and when it applies (1-2 sentences). "
|
|
88
|
+
"Empty when the session doesn't support one; never generic advice or the title again"},
|
|
89
|
+
"checklist": {"type": "array", "items": {"type": "string"}, "description": "fix, gotcha, decision, learning, "
|
|
90
|
+
"pattern: up to 4 concrete checks for similar work next time, grounded in what the session found. "
|
|
91
|
+
"Empty otherwise"},
|
|
92
|
+
"topics": {"type": "array", "items": {"type": "string", "enum": list(TOPICS)},
|
|
93
|
+
"description": "Up to 4 engineering areas this item actually teaches. Empty for other kinds"},
|
|
94
|
+
"visual": VISUAL_SCHEMA,
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
FRICTION_SCHEMA = {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": False,
|
|
100
|
+
"required": ["kind", "note"],
|
|
101
|
+
"properties": {"kind": {"type": "string", "enum": FRICTION_KINDS}, "note": {"type": "string"}},
|
|
102
|
+
}
|
|
103
|
+
INTEREST_SCHEMA = {"type": "array", "items": {"type": "object", "additionalProperties": False,
|
|
104
|
+
"required": ["topic", "question"], "properties": {
|
|
105
|
+
"topic": {"type": "string", "enum": list(TOPICS)},
|
|
106
|
+
"question": {"type": "string", "description": "Verbatim quote of the USER's question or expressed curiosity; never assistant text."}}},
|
|
107
|
+
"description": "Only explicit curiosity, requests for explanation, comparison or understanding. Execution tasks alone are not learning interests. Empty if none."}
|
|
108
|
+
ANALYSIS_SCHEMA = {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"additionalProperties": False,
|
|
111
|
+
"required": ["title", "summary", "goal", "outcome", "outcome_note", "work_types", "tags", "highlights",
|
|
112
|
+
"knowledge", "open_threads", "friction", "sentiment", "learning_interests"],
|
|
113
|
+
"properties": {
|
|
114
|
+
"title": {"type": "string", "description": "Specific title of what the session was about, at most ~70 characters"},
|
|
115
|
+
"summary": {"type": "string", "description": "3-6 sentences: what was asked, what was done, how it ended"},
|
|
116
|
+
"goal": {"type": "string", "description": "The developer's underlying goal, one sentence"},
|
|
117
|
+
"outcome": {"type": "string", "enum": OUTCOMES},
|
|
118
|
+
"outcome_note": {"type": "string", "description": "One sentence on the end state"},
|
|
119
|
+
"work_types": {"type": "array", "items": {"type": "string", "enum": WORK_TYPES}},
|
|
120
|
+
"tags": {"type": "array", "items": {"type": "string"}, "description": "3-8 lowercase topic and technology tags"},
|
|
121
|
+
"highlights": {"type": "array", "items": {"type": "string"}, "description": "Key accomplishments or findings, one line each"},
|
|
122
|
+
"knowledge": {"type": "array", "items": KNOWLEDGE_ITEM_SCHEMA},
|
|
123
|
+
"learning_interests": INTEREST_SCHEMA,
|
|
124
|
+
"open_threads": {"type": "array", "items": {"type": "string"}, "description": "Unfinished work, follow-ups, open questions"},
|
|
125
|
+
"friction": {"type": "array", "items": FRICTION_SCHEMA, "description": "What slowed the session down"},
|
|
126
|
+
"sentiment": {"type": "string", "enum": ["positive", "neutral", "frustrated", "mixed", "unclear"],
|
|
127
|
+
"description": "The developer's apparent sentiment by the end"},
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
CHUNK_SCHEMA = {
|
|
131
|
+
"type": "object",
|
|
132
|
+
"additionalProperties": False,
|
|
133
|
+
"required": ["summary", "highlights", "knowledge", "open_threads", "friction", "tags", "learning_interests"],
|
|
134
|
+
"properties": {
|
|
135
|
+
"summary": {"type": "string", "description": "What happened in this part, 3-6 sentences"},
|
|
136
|
+
"highlights": {"type": "array", "items": {"type": "string"}},
|
|
137
|
+
"knowledge": {"type": "array", "items": KNOWLEDGE_ITEM_SCHEMA},
|
|
138
|
+
"learning_interests": INTEREST_SCHEMA,
|
|
139
|
+
"open_threads": {"type": "array", "items": {"type": "string"}},
|
|
140
|
+
"friction": {"type": "array", "items": FRICTION_SCHEMA},
|
|
141
|
+
"tags": {"type": "array", "items": {"type": "string"}},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
# The merge of a long session's parts names the part notes' knowledge items it keeps instead of writing them out
|
|
145
|
+
# again: retyping a few dozen lessons with their case files in one reply came back empty, and wiped the session's.
|
|
146
|
+
REDUCE_SCHEMA = {
|
|
147
|
+
**ANALYSIS_SCHEMA,
|
|
148
|
+
"required": [k if k != "knowledge" else "knowledge_ids" for k in ANALYSIS_SCHEMA["required"]],
|
|
149
|
+
"properties": {**{k: v for k, v in ANALYSIS_SCHEMA["properties"].items() if k != "knowledge"},
|
|
150
|
+
"knowledge_ids": {"type": "array", "items": {"type": "string"},
|
|
151
|
+
"description": "The ids of the part notes' knowledge items to keep, in order"}},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
SYSTEM_PROMPT = """\
|
|
155
|
+
You are Interlatch, an analyst that turns AI coding-agent session transcripts (Claude Code, OpenAI Codex, GitHub Copilot, IBM Bob, \
|
|
156
|
+
Google Antigravity) into a durable record and reusable knowledge for the developer who ran them.
|
|
157
|
+
|
|
158
|
+
You receive metadata (including which agent ran the session) and a condensed transcript of one session: a \
|
|
159
|
+
developer (USER) working with an AI coding agent (CLAUDE, CODEX, COPILOT, BOB or ANTIGRAVITY). Tool calls appear as "→ Tool: summary", \
|
|
160
|
+
tool output excerpts as "⮑" and failed tool calls as "✗". Long outputs are truncated and secrets are redacted.
|
|
161
|
+
|
|
162
|
+
Produce two things:
|
|
163
|
+
1. An accurate overview: a specific title, what the developer wanted, what was done, and how it ended.
|
|
164
|
+
2. Knowledge items: what is worth remembering because it would save this developer time in a future session.
|
|
165
|
+
|
|
166
|
+
Good knowledge items are:
|
|
167
|
+
- Specific and non-obvious: root causes with their fixes, gotchas, design decisions with their reasons, \
|
|
168
|
+
facts about the project (where things live; how to run, test or deploy it; which service does what), commands \
|
|
169
|
+
or snippets that worked, and preferences the developer expressed about how the agent should work.
|
|
170
|
+
- Self-contained: someone who never saw this session must understand the item. Name the concrete files, \
|
|
171
|
+
commands, config keys, versions and error messages.
|
|
172
|
+
- Grounded: include only what the transcript supports. Confidence is "high" when the session verified it \
|
|
173
|
+
(tests passed, the developer confirmed), "medium" when likely but unverified, "low" for hypotheses.
|
|
174
|
+
|
|
175
|
+
Leave out generic programming advice, restatements of the task, and trivia that is quick to rediscover. \
|
|
176
|
+
A routine session may have few or no knowledge items; that is a correct answer.
|
|
177
|
+
|
|
178
|
+
The developer also reads fixes, gotchas and decisions as case files, which ask before they tell: the scene, then the \
|
|
179
|
+
question, and only then the answer. For those three kinds, fill in:
|
|
180
|
+
- scene: what was being done and what was seen first, before the cause was known (the symptom, the error message \
|
|
181
|
+
verbatim). For a decision, the problem it had to settle. The scene must not give the answer away.
|
|
182
|
+
- question: the question the scene raises, as the developer would have asked it then ("Why didn't the edit show up?").
|
|
183
|
+
- answer: the cause, or the call made, in at most 12 words. The explanation and the fix belong in the body.
|
|
184
|
+
- ruled_out: the leads the session actually tried and found wrong, each with what showed it wrong. Phrase each lead \
|
|
185
|
+
like the answer, also in at most 12 words, so it reads as an alternative to it and the right one doesn't stand out \
|
|
186
|
+
by its length. For a decision, the options weighed and turned down. Only dead ends the transcript shows: an invented \
|
|
187
|
+
one would teach something false. Leave it empty when there were none.
|
|
188
|
+
For every other kind, leave these four empty.
|
|
189
|
+
|
|
190
|
+
The developer also learns from fixes, gotchas, decisions, learnings and patterns, so for those five kinds fill in, \
|
|
191
|
+
only where the transcript supports it:
|
|
192
|
+
- principle: the general idea the item is an instance of, stated so it applies beyond this project, and when it \
|
|
193
|
+
applies ("A write that replaces the whole set must read the set when it is applied, not when it was planned"). Not \
|
|
194
|
+
generic advice and not the title again; leave it empty rather than stretch.
|
|
195
|
+
- checklist: up to 4 concrete checks for similar work next time.
|
|
196
|
+
- clues: for a fix or gotcha, the facts observed in order before the cause was known.
|
|
197
|
+
- topics: the engineering areas the item actually teaches.
|
|
198
|
+
- visual: a small diagram, only when the explanation describes a flow, parts that connect, or real alternatives: \
|
|
199
|
+
2-4 nodes named in the session's own terms, each with a one-sentence detail of its role, and up to 5 labeled edges. \
|
|
200
|
+
Use flow for request paths or steps, relationship for components, data entities or state ownership, comparison for \
|
|
201
|
+
alternatives weighed. Draw only what the explanation establishes, never a generic architecture from topic tags; \
|
|
202
|
+
otherwise return an empty visual (type flow, empty title, nodes and edges). Two boxes saying "problem" and "solution" \
|
|
203
|
+
are not a diagram.
|
|
204
|
+
When the session revised a finding or a decision, the title, answer and principle state where it ended up; the body \
|
|
205
|
+
says what it replaced.
|
|
206
|
+
|
|
207
|
+
Extract the concepts the USER is curious about: questions about why or how, follow-up explanations, comparisons, \
|
|
208
|
+
architecture tradeoffs, frontend/backend behavior, API contracts and data models. Quote the user's actual question \
|
|
209
|
+
in learning_interests and classify its topic. A delegated implementation task or an assistant's unsolicited \
|
|
210
|
+
explanation is not evidence of curiosity. Preserve substantive answers as learning or pattern items, even when no \
|
|
211
|
+
bug occurred. Connect the concept to the actual project and retain its explanation, not just the task's outcome.
|
|
212
|
+
|
|
213
|
+
Use scope "global" for items useful beyond this project (tools, languages, platforms, the developer's working \
|
|
214
|
+
preferences), otherwise "project". Write in English, but keep identifiers, error messages and quotes verbatim, \
|
|
215
|
+
whatever their language."""
|
|
216
|
+
WRITE_ENGLISH = "Write in English, but keep identifiers, error messages and quotes verbatim, whatever their language."
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def system_prompt(cfg: Config) -> str:
|
|
220
|
+
"""SYSTEM_PROMPT in the language Interlatch writes in ([analysis] language)."""
|
|
221
|
+
return written_in(cfg, SYSTEM_PROMPT, WRITE_ENGLISH, "Tags stay short lowercase English terms, so sessions group by "
|
|
222
|
+
"topic whichever language they were analyzed in.")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _session_prompt(header: str, digest: str) -> str:
|
|
226
|
+
return (
|
|
227
|
+
f"<session_metadata>\n{header}\n</session_metadata>\n\n"
|
|
228
|
+
f"<transcript>\n{digest}\n</transcript>\n\n"
|
|
229
|
+
"Analyze this session."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _chunk_prompt(header: str, chunk: str, i: int, n: int) -> str:
|
|
234
|
+
return (
|
|
235
|
+
f"<session_metadata>\n{header}\n</session_metadata>\n\n"
|
|
236
|
+
f"This is part {i} of {n} of a long session transcript. Extract notes for this part only; "
|
|
237
|
+
"all parts will be merged afterwards.\n\n"
|
|
238
|
+
f"<transcript_part index=\"{i}\" of=\"{n}\">\n{chunk}\n</transcript_part>"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _reduce_prompt(header: str, notes: list[dict], opening: str, closing: str) -> str:
|
|
243
|
+
parts = "\n\n".join(f"[part {i}]\n{json.dumps(n, ensure_ascii=False, indent=1)}" for i, n in enumerate(notes, 1))
|
|
244
|
+
return (
|
|
245
|
+
f"<session_metadata>\n{header}\n</session_metadata>\n\n"
|
|
246
|
+
f"The session was too long for one pass, so it was analyzed in {len(notes)} parts. "
|
|
247
|
+
"Notes from each part, in order:\n\n"
|
|
248
|
+
f"<part_notes>\n{parts}\n</part_notes>\n\n"
|
|
249
|
+
f"<opening_excerpt>\n{opening}\n</opening_excerpt>\n\n"
|
|
250
|
+
f"<closing_excerpt>\n{closing}\n</closing_excerpt>\n\n"
|
|
251
|
+
"Write the final analysis of the whole session, and describe the end state from the last part. For "
|
|
252
|
+
"knowledge, list in knowledge_ids the ids of the part notes' items to keep: of duplicates only the most "
|
|
253
|
+
"specific, of a finding a later part revised only the later one, and none that later parts show to be wrong."
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _kept(items: dict[str, dict], ids) -> list[dict]:
|
|
258
|
+
"""The part notes' knowledge items the merge kept, each once. All of them when it named none it was given: a
|
|
259
|
+
reply that lost its list must not replace the session's lessons with nothing."""
|
|
260
|
+
kept = [items[i] for i in dict.fromkeys(ids if isinstance(ids, list) else []) if isinstance(i, str) and i in items]
|
|
261
|
+
if not kept and items:
|
|
262
|
+
log.warning("the merge kept no knowledge item; keeping all %d from the parts", len(items))
|
|
263
|
+
return kept or list(items.values())
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _str_list(value) -> list[str]:
|
|
267
|
+
if isinstance(value, str):
|
|
268
|
+
return [value] if value.strip() else []
|
|
269
|
+
if not isinstance(value, list):
|
|
270
|
+
return []
|
|
271
|
+
return [str(v).strip() for v in value if str(v).strip()]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _leads(value) -> list[dict]:
|
|
275
|
+
out = []
|
|
276
|
+
for lead in value if isinstance(value, list) else []:
|
|
277
|
+
if isinstance(lead, str):
|
|
278
|
+
lead = {"lead": lead}
|
|
279
|
+
if isinstance(lead, dict) and str(lead.get("lead") or "").strip():
|
|
280
|
+
out.append({"lead": str(lead["lead"]).strip(), "why": str(lead.get("why") or "").strip()})
|
|
281
|
+
return out[:MAX_LEADS]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def normalize_knowledge(items) -> list[dict]:
|
|
285
|
+
out = []
|
|
286
|
+
for item in items if isinstance(items, list) else []:
|
|
287
|
+
if not isinstance(item, dict) or not str(item.get("title") or "").strip():
|
|
288
|
+
continue
|
|
289
|
+
kind = str(item.get("kind") or "").lower().strip()
|
|
290
|
+
kind = kind if kind in KNOWLEDGE_KINDS else "learning"
|
|
291
|
+
case = kind in CASE_KINDS
|
|
292
|
+
out.append({
|
|
293
|
+
"kind": kind,
|
|
294
|
+
"title": str(item["title"]).strip(),
|
|
295
|
+
"body": str(item.get("body") or "").strip(),
|
|
296
|
+
"tags": [t.lower() for t in _str_list(item.get("tags"))][:8],
|
|
297
|
+
"scope": item.get("scope") if item.get("scope") in ("project", "global") else "project",
|
|
298
|
+
"confidence": item.get("confidence") if item.get("confidence") in ("high", "medium", "low") else "medium",
|
|
299
|
+
"evidence": str(item.get("evidence") or "").strip(),
|
|
300
|
+
**{k: str(item.get(k) or "").strip() if case else "" for k in ("scene", "question", "answer")},
|
|
301
|
+
"ruled_out": _leads(item.get("ruled_out")) if case else [],
|
|
302
|
+
**(lesson_of(item) if kind in LESSON_KINDS else {}),
|
|
303
|
+
})
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def case_of(item: dict) -> dict | None:
|
|
308
|
+
"""A lesson's material, redacted: for a fix, gotcha or decision its case file (scene, question, answer, ruled_out)
|
|
309
|
+
when the analysis gave one, and for any lesson kind the principle, checks, topics and diagram it supported (cases.py).
|
|
310
|
+
`item` is an analysis item, or {"kind", "case": stored case_json}. None when there is nothing."""
|
|
311
|
+
kind = item.get("kind")
|
|
312
|
+
if kind not in LESSON_KINDS:
|
|
313
|
+
return None
|
|
314
|
+
raw = item["case"] if isinstance(item.get("case"), dict) else item
|
|
315
|
+
out = {}
|
|
316
|
+
scene, question, answer = (redact(raw[k]).strip() if isinstance(raw.get(k), str) else "" for k in ("scene", "question", "answer"))
|
|
317
|
+
if kind in CASE_KINDS and scene and question and answer:
|
|
318
|
+
out = {"scene": scene, "question": question, "answer": answer,
|
|
319
|
+
"ruled_out": [{"lead": redact(x["lead"]), "why": redact(x["why"])} for x in _leads(raw.get("ruled_out"))]}
|
|
320
|
+
return {**out, **lesson_of(raw)} or None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def normalize_analysis(data: dict) -> dict:
|
|
324
|
+
"""Coerce a model reply into the analysis shape; missing optional fields get neutral defaults."""
|
|
325
|
+
friction = []
|
|
326
|
+
for f in data.get("friction") or []:
|
|
327
|
+
if isinstance(f, dict) and f.get("note"):
|
|
328
|
+
friction.append({"kind": f.get("kind") if f.get("kind") in FRICTION_KINDS else "other", "note": str(f["note"])})
|
|
329
|
+
elif isinstance(f, str) and f.strip():
|
|
330
|
+
friction.append({"kind": "other", "note": f.strip()})
|
|
331
|
+
outcome = str(data.get("outcome") or "").lower()
|
|
332
|
+
sentiment = str(data.get("sentiment") or "").lower()
|
|
333
|
+
return {
|
|
334
|
+
"title": str(data.get("title") or "").strip(),
|
|
335
|
+
"summary": str(data.get("summary") or "").strip(),
|
|
336
|
+
"goal": str(data.get("goal") or "").strip(),
|
|
337
|
+
"outcome": outcome if outcome in OUTCOMES else "unclear",
|
|
338
|
+
"outcome_note": str(data.get("outcome_note") or "").strip(),
|
|
339
|
+
"work_types": [w for w in _str_list(data.get("work_types")) if w in WORK_TYPES],
|
|
340
|
+
"tags": [t.lower() for t in _str_list(data.get("tags"))][:10],
|
|
341
|
+
"highlights": _str_list(data.get("highlights")),
|
|
342
|
+
"knowledge": normalize_knowledge(data.get("knowledge")),
|
|
343
|
+
"learning_interests": normalize_interests(data.get("learning_interests")),
|
|
344
|
+
"open_threads": _str_list(data.get("open_threads")),
|
|
345
|
+
"friction": friction,
|
|
346
|
+
"sentiment": sentiment if sentiment in ("positive", "neutral", "frustrated", "mixed", "unclear") else "unclear",
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class AnalysisSkipped(Exception):
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _log_run(conn, kind: str, target: str, res: LLMResult | None, *, status: str, error: str | None = None,
|
|
355
|
+
input_chars: int = 0, chunks: int = 1, started: str | None = None, result: dict | None = None) -> None:
|
|
356
|
+
conn.execute(
|
|
357
|
+
"INSERT INTO analyses(kind, target, started_at, finished_at, model, status, error, input_chars, chunks, "
|
|
358
|
+
"cost_usd, duration_ms, result_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
359
|
+
(kind, target, started, utcnow_iso(), res.model if res else None, status, error, input_chars, chunks,
|
|
360
|
+
res.cost_usd if res else None, res.duration_ms if res else None, dumps(result) if result else None),
|
|
361
|
+
)
|
|
362
|
+
conn.commit() # never hold a write transaction open across the next (minutes-long) Claude call
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def analyze_session(conn: sqlite3.Connection, cfg: Config, session_id: str, runner: Runner | None = None,
|
|
366
|
+
*, model: str | None = None) -> dict:
|
|
367
|
+
"""Analyze one session and store the overview + knowledge. Raises LLM errors after recording them."""
|
|
368
|
+
runner = runner or make_runner(cfg)
|
|
369
|
+
s = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
|
|
370
|
+
if s is None:
|
|
371
|
+
raise KeyError(session_id)
|
|
372
|
+
if s["source"] == "history":
|
|
373
|
+
raise AnalysisSkipped("history-only session has no transcript")
|
|
374
|
+
if s["source"] == "remote":
|
|
375
|
+
raise AnalysisSkipped("analyzed on the computer it ran on, which keeps its transcript")
|
|
376
|
+
started = utcnow_iso()
|
|
377
|
+
conn.execute("UPDATE sessions SET analysis_status = 'running' WHERE id = ?", (session_id,))
|
|
378
|
+
conn.commit()
|
|
379
|
+
header, digest = build_digest(conn, session_id, runner.chunk_chars)
|
|
380
|
+
if len(digest.text) < 200 and s["n_prompts"] <= 1:
|
|
381
|
+
conn.execute("UPDATE sessions SET analysis_status='skipped', analysis_reason='too little content' WHERE id=?", (session_id,))
|
|
382
|
+
conn.commit()
|
|
383
|
+
raise AnalysisSkipped("too little content")
|
|
384
|
+
total_cost = 0.0
|
|
385
|
+
system = system_prompt(cfg)
|
|
386
|
+
try:
|
|
387
|
+
if len(digest.chunks) == 1:
|
|
388
|
+
res = runner.run(_session_prompt(header, digest.chunks[0]), ANALYSIS_SCHEMA, system=system, model=model)
|
|
389
|
+
total_cost += res.cost_usd
|
|
390
|
+
data = normalize_analysis(res.data)
|
|
391
|
+
else:
|
|
392
|
+
notes, items = [], {}
|
|
393
|
+
for i, chunk in enumerate(digest.chunks, 1):
|
|
394
|
+
part = runner.run(_chunk_prompt(header, chunk, i, len(digest.chunks)), CHUNK_SCHEMA, system=system, model=model)
|
|
395
|
+
total_cost += part.cost_usd
|
|
396
|
+
_log_run(conn, "chunk", session_id, part, status="done", input_chars=len(chunk), started=started)
|
|
397
|
+
shown = [] # the merge judges each item by what it says; the full item is kept here, by id
|
|
398
|
+
for j, item in enumerate(normalize_knowledge(part.data.get("knowledge")), 1):
|
|
399
|
+
items[f"p{i}.{j}"] = item
|
|
400
|
+
shown.append({"id": f"p{i}.{j}", **{k: item[k] for k in ("kind", "title", "body", "confidence")}})
|
|
401
|
+
notes.append({**part.data, "knowledge": shown})
|
|
402
|
+
res = runner.run(
|
|
403
|
+
_reduce_prompt(header, notes, digest.chunks[0][:6000], digest.chunks[-1][-6000:]),
|
|
404
|
+
REDUCE_SCHEMA, system=system, model=model,
|
|
405
|
+
)
|
|
406
|
+
total_cost += res.cost_usd
|
|
407
|
+
data = {**normalize_analysis(res.data), "knowledge": _kept(items, res.data.get("knowledge_ids"))}
|
|
408
|
+
except (UsageLimitError, BudgetExceededError, LLMError) as exc:
|
|
409
|
+
if isinstance(exc, (UsageLimitError, SleepInterruptedError)): # not the session's fault: just retry later
|
|
410
|
+
status, attempts, not_before = "pending", s["analysis_attempts"] or 0, None
|
|
411
|
+
elif isinstance(exc, BudgetExceededError): # retrying cannot help until the budget is raised
|
|
412
|
+
status, attempts, not_before = "error", 99, None
|
|
413
|
+
else: # transient: back off 30m, 2h, 8h
|
|
414
|
+
attempts = (s["analysis_attempts"] or 0) + 1
|
|
415
|
+
status, not_before = "error", to_iso(utcnow() + timedelta(minutes=30 * 4 ** (attempts - 1)))
|
|
416
|
+
conn.execute(
|
|
417
|
+
"UPDATE sessions SET analysis_status=?, analysis_reason=?, analysis_attempts=?, analysis_not_before=? WHERE id=?",
|
|
418
|
+
(status, str(exc)[:500], attempts, not_before, session_id),
|
|
419
|
+
)
|
|
420
|
+
_log_run(conn, "session", session_id, None, status="error", error=str(exc)[:1000],
|
|
421
|
+
input_chars=digest.chars, chunks=len(digest.chunks), started=started)
|
|
422
|
+
conn.commit()
|
|
423
|
+
raise
|
|
424
|
+
res.cost_usd = total_cost
|
|
425
|
+
store_analysis(conn, cfg, session_id, data, res.model, s["n_prompts"])
|
|
426
|
+
_log_run(conn, "session", session_id, res, status="done", input_chars=digest.chars,
|
|
427
|
+
chunks=len(digest.chunks), started=started, result=data)
|
|
428
|
+
conn.commit()
|
|
429
|
+
log.info("analyzed %s (%s chars, %d chunk(s), level %d, $%.3f)", session_id, digest.chars,
|
|
430
|
+
len(digest.chunks), digest.level, total_cost)
|
|
431
|
+
return data
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def store_analysis(conn: sqlite3.Connection, cfg: Config, session_id: str, data: dict, model: str | None,
|
|
435
|
+
n_prompts: int) -> None:
|
|
436
|
+
store_interests(conn, session_id, data.get("learning_interests"))
|
|
437
|
+
# Curiosity quotes stay in local kv; shared analysis_json contains only the teaching material.
|
|
438
|
+
data = {key: value for key, value in data.items() if key != "learning_interests"}
|
|
439
|
+
s = conn.execute("SELECT project_path, project_name, ai_title, first_prompt, machine_id, source FROM sessions "
|
|
440
|
+
"WHERE id = ?", (session_id,)).fetchone()
|
|
441
|
+
title = (data.get("title") or "").strip() or None
|
|
442
|
+
# prompts that arrived while Claude was analyzing leave the session stale, so they get analyzed too
|
|
443
|
+
conn.execute(
|
|
444
|
+
"UPDATE sessions SET analysis_status = CASE WHEN n_prompts > ? THEN 'stale' ELSE 'done' END, "
|
|
445
|
+
"analysis_reason = CASE WHEN n_prompts > ? THEN 'session continued during analysis' END, "
|
|
446
|
+
"analysis_attempts=0, analysis_not_before=NULL, "
|
|
447
|
+
"analyzed_at=?, analysis_model=?, analyzed_prompts=?, llm_title=?, summary=?, goal=?, outcome=?, outcome_note=?, "
|
|
448
|
+
"sentiment=?, work_types_json=?, tags_json=?, highlights_json=?, open_threads_json=?, friction_json=?, "
|
|
449
|
+
"analysis_json=?, title=? WHERE id=?",
|
|
450
|
+
(
|
|
451
|
+
n_prompts, n_prompts, utcnow_iso(), model, n_prompts, title, redact(data.get("summary")), redact(data.get("goal")),
|
|
452
|
+
data.get("outcome"), redact(data.get("outcome_note")), data.get("sentiment"),
|
|
453
|
+
dumps(data.get("work_types") or []), dumps([t.lower() for t in data.get("tags") or []]),
|
|
454
|
+
dumps([redact(h) for h in data.get("highlights") or []]),
|
|
455
|
+
dumps([redact(o) for o in data.get("open_threads") or []]),
|
|
456
|
+
dumps(data.get("friction") or []), dumps(data),
|
|
457
|
+
best_title({"llm_title": title, "ai_title": s["ai_title"], "first_prompt": s["first_prompt"]}), session_id,
|
|
458
|
+
),
|
|
459
|
+
)
|
|
460
|
+
kept = ladder.before_reanalysis(conn, session_id)
|
|
461
|
+
from .ingest import local_machine_id
|
|
462
|
+
|
|
463
|
+
# a hub analyzing another computer's transcript: the lessons new to it are the Team page's news (news.py)
|
|
464
|
+
other = s["machine_id"] if s["machine_id"] and s["machine_id"] != local_machine_id(cfg) and s["source"] != "history" else None
|
|
465
|
+
before = news.lesson_keys(conn, session_id) if other else None
|
|
466
|
+
conn.execute(
|
|
467
|
+
"DELETE FROM knowledge WHERE session_id = ? AND source = 'analysis' AND pinned = 0 AND status != 'dismissed'",
|
|
468
|
+
(session_id,),
|
|
469
|
+
)
|
|
470
|
+
now = utcnow_iso()
|
|
471
|
+
for item in data.get("knowledge") or []:
|
|
472
|
+
if not item.get("title") or item.get("kind") not in KNOWLEDGE_KINDS:
|
|
473
|
+
continue
|
|
474
|
+
case = case_of(item)
|
|
475
|
+
# a pinned or dismissed item with the same fingerprint is kept as it is, but gains lesson material it lacked,
|
|
476
|
+
# and a diagram it lacked while its text is unchanged
|
|
477
|
+
conn.execute(
|
|
478
|
+
"INSERT INTO knowledge(session_id, project_path, project_name, kind, title, body, tags_json, scope, "
|
|
479
|
+
"confidence, evidence, source, source_ref, fingerprint, created_at, updated_at, stage, case_json) "
|
|
480
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
|
481
|
+
"ON CONFLICT(fingerprint) DO UPDATE SET case_json = CASE WHEN knowledge.case_json IS NULL "
|
|
482
|
+
"THEN excluded.case_json WHEN knowledge.body = excluded.body AND json_valid(knowledge.case_json) "
|
|
483
|
+
"AND json_extract(knowledge.case_json, '$.visual') IS NULL "
|
|
484
|
+
"AND json_extract(excluded.case_json, '$.visual') IS NOT NULL "
|
|
485
|
+
"THEN json_set(knowledge.case_json, '$.visual', json_extract(excluded.case_json, '$.visual')) "
|
|
486
|
+
"ELSE knowledge.case_json END",
|
|
487
|
+
(
|
|
488
|
+
session_id, s["project_path"], s["project_name"], item["kind"], redact(item["title"]).strip(),
|
|
489
|
+
redact(item.get("body") or "").strip(), dumps([t.lower() for t in item.get("tags") or []]),
|
|
490
|
+
item.get("scope") if item.get("scope") in ("project", "global") else "project",
|
|
491
|
+
item.get("confidence"), redact(item.get("evidence") or ""), "analysis", f"prompt-v{PROMPT_VERSION}",
|
|
492
|
+
fingerprint(session_id, item["kind"], item["title"]), now, now, ladder.initial_stage(item),
|
|
493
|
+
dumps(case) if case else None,
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
ladder.after_reanalysis(conn, session_id, kept)
|
|
497
|
+
if other:
|
|
498
|
+
news.record(conn, other, s["project_path"], lessons=news.learned(conn, session_id, before))
|