ccrecall 0.10.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.
- ccrecall/__init__.py +18 -0
- ccrecall/cli/__init__.py +98 -0
- ccrecall/cli/commands.py +245 -0
- ccrecall/cli/context.py +31 -0
- ccrecall/content.py +152 -0
- ccrecall/db.py +306 -0
- ccrecall/embeddings.py +125 -0
- ccrecall/formatting.py +144 -0
- ccrecall/fusion.py +17 -0
- ccrecall/hooks/__init__.py +0 -0
- ccrecall/hooks/backfill_embeddings.py +374 -0
- ccrecall/hooks/backfill_summaries.py +99 -0
- ccrecall/hooks/clear_handoff.py +49 -0
- ccrecall/hooks/import_conversations.py +271 -0
- ccrecall/hooks/memory_context.py +539 -0
- ccrecall/hooks/memory_setup.py +162 -0
- ccrecall/hooks/memory_sync.py +57 -0
- ccrecall/hooks/onboarding.py +83 -0
- ccrecall/hooks/sync_current.py +154 -0
- ccrecall/hooks/write_config.py +51 -0
- ccrecall/models.py +121 -0
- ccrecall/parsing.py +314 -0
- ccrecall/project_ops.py +92 -0
- ccrecall/recent_chats.py +204 -0
- ccrecall/schema.py +192 -0
- ccrecall/search_conversations.py +500 -0
- ccrecall/serialization.py +41 -0
- ccrecall/session_ops.py +563 -0
- ccrecall/session_tail.py +384 -0
- ccrecall/summarizer.py +427 -0
- ccrecall/token_analytics.py +212 -0
- ccrecall/token_dashboard.py +119 -0
- ccrecall/token_insights.py +862 -0
- ccrecall/token_output.py +867 -0
- ccrecall/token_parser.py +661 -0
- ccrecall/token_schema.py +206 -0
- ccrecall-0.10.0.dist-info/METADATA +244 -0
- ccrecall-0.10.0.dist-info/RECORD +42 -0
- ccrecall-0.10.0.dist-info/WHEEL +5 -0
- ccrecall-0.10.0.dist-info/entry_points.txt +7 -0
- ccrecall-0.10.0.dist-info/licenses/LICENSE +21 -0
- ccrecall-0.10.0.dist-info/top_level.txt +1 -0
ccrecall/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ccrecall — conversation memory package for Claude Code.
|
|
3
|
+
|
|
4
|
+
Submodules:
|
|
5
|
+
db — Database connection, config/settings, vec operations, logging
|
|
6
|
+
schema — Conversation DB schema constants (SCHEMA_*) and FTS detection
|
|
7
|
+
content — Message content extraction and tool detection
|
|
8
|
+
parsing — JSONL parsing, branch detection, metadata extraction
|
|
9
|
+
formatting — Session formatting, time/path utilities
|
|
10
|
+
project_ops — Shared project upsert logic (cwd strategy + JSONL-probe strategy)
|
|
11
|
+
session_ops — Shared session import logic (used by sync and import pipelines)
|
|
12
|
+
token_schema — Token ingest schema definitions, ensure_schema(), version management
|
|
13
|
+
token_parser — Token JSONL parsing, data classes, session parsing, file discovery
|
|
14
|
+
token_analytics — Session import and token_snapshots backfill
|
|
15
|
+
token_output — Dashboard JSON output assembly (chart queries)
|
|
16
|
+
token_insights — Trend analysis, insight generation, findings/recommendations
|
|
17
|
+
token_dashboard — Token dashboard deployment and main() entry point
|
|
18
|
+
"""
|
ccrecall/cli/__init__.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""ccrecall CLI — single entry point consolidating the former cm-* binaries.
|
|
2
|
+
|
|
3
|
+
Root ``App`` plus a ``backfill`` sub-``App``. Command functions live in
|
|
4
|
+
``commands.py`` and register themselves on import (the import at the bottom of
|
|
5
|
+
this module triggers that registration).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import inspect
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
|
|
12
|
+
from cyclopts import App, Group, Parameter
|
|
13
|
+
from cyclopts.exceptions import CycloptsError
|
|
14
|
+
|
|
15
|
+
from ccrecall.cli.context import CLIContext
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
_version = version("ccrecall")
|
|
19
|
+
except PackageNotFoundError:
|
|
20
|
+
_version = "unknown"
|
|
21
|
+
|
|
22
|
+
app = App(
|
|
23
|
+
name="ccrecall",
|
|
24
|
+
version=_version,
|
|
25
|
+
version_flags=["--version", "-V"],
|
|
26
|
+
help="Conversation history and semantic search for Claude Code.",
|
|
27
|
+
# Plaintext so the examples block keeps its line breaks and literal <…>
|
|
28
|
+
# placeholders (markdown/rst reflow them and strip the angle brackets).
|
|
29
|
+
help_format="plaintext",
|
|
30
|
+
help_epilogue=(
|
|
31
|
+
"Examples:\n"
|
|
32
|
+
" ccrecall recent --n 5\n"
|
|
33
|
+
" ccrecall --json search -q 'auth bug'\n"
|
|
34
|
+
" ccrecall tail <session-id>\n"
|
|
35
|
+
" ccrecall backfill embeddings --status"
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Global options live on the meta launcher; group them so they render under one
|
|
40
|
+
# heading in `ccrecall --help` rather than scattered among the subcommands.
|
|
41
|
+
app.meta.group_parameters = Group("Global Options", sort_key=0)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
backfill_app = App(name="backfill", help="Seed historical summaries and embeddings.")
|
|
45
|
+
app.command(backfill_app)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.meta.default
|
|
49
|
+
def launcher(
|
|
50
|
+
*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)],
|
|
51
|
+
json_mode: Annotated[
|
|
52
|
+
bool,
|
|
53
|
+
Parameter(name=["--json"], help="Emit machine-readable JSON instead of markdown.", negative=[]),
|
|
54
|
+
] = False,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Parse global options into a CLIContext, then dispatch to the chosen command.
|
|
57
|
+
|
|
58
|
+
The single global ``--json`` is the only output-format surface — commands no
|
|
59
|
+
longer carry their own ``--json``/``--format`` flag, which is what keeps the
|
|
60
|
+
contract from drifting. ``parse_args`` here mirrors the app-level error
|
|
61
|
+
contract (boxed message, raise instead of exit) so ``main`` can force exit 2.
|
|
62
|
+
"""
|
|
63
|
+
ctx = CLIContext(json_mode=json_mode)
|
|
64
|
+
# print_error=True is load-bearing: a CycloptsError escaping a meta.default
|
|
65
|
+
# body is NOT re-rendered by the outer app.meta(), so this inner call is the
|
|
66
|
+
# only thing that prints the boxed message. exit_on_error=False makes it
|
|
67
|
+
# raise instead, so main()'s handler can force exit 2.
|
|
68
|
+
command, bound, _ = app.parse_args(tokens, print_error=True, exit_on_error=False)
|
|
69
|
+
# Inject ctx only where the command declares it (recent/search/backfill
|
|
70
|
+
# embeddings) — output-less commands (hooks, tail, import) omit the param and
|
|
71
|
+
# ignore a global --json. The third parse_args return value carries the ctx
|
|
72
|
+
# *type*, not an instance, so it can't do the injection; we set it by hand.
|
|
73
|
+
# ctx must stay keyword-only in every command for **bound.kwargs to carry it.
|
|
74
|
+
if "ctx" in inspect.signature(command).parameters:
|
|
75
|
+
bound.arguments["ctx"] = ctx
|
|
76
|
+
command(*bound.args, **bound.kwargs)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main() -> None:
|
|
80
|
+
"""Console-script entry point.
|
|
81
|
+
|
|
82
|
+
Wraps the cyclopts meta app so every argument-parsing error exits 2 — the
|
|
83
|
+
usual usage-error code — carrying cyclopts' boxed message, so parser errors
|
|
84
|
+
agree with the app-level validators. A command that raises its own SystemExit
|
|
85
|
+
bypasses this handler (SystemExit is not a CycloptsError), keeping its code.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
app.meta(exit_on_error=False, print_error=True)
|
|
89
|
+
except CycloptsError as exc:
|
|
90
|
+
raise SystemExit(2) from exc
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Importing the commands module registers every subcommand on ``app`` /
|
|
94
|
+
# ``backfill_app`` via the @app.command decorators it defines. Kept at the
|
|
95
|
+
# bottom so ``app`` and ``backfill_app`` exist before the decorators run. The
|
|
96
|
+
# redundant ``as commands`` alias marks it as an intentional side-effect import,
|
|
97
|
+
# so ruff and pyright don't flag it unused without needing a separate sentinel.
|
|
98
|
+
from ccrecall.cli import commands as commands # noqa: E402
|
ccrecall/cli/commands.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""ccrecall subcommand definitions.
|
|
2
|
+
|
|
3
|
+
Each command is a thin cyclopts wrapper that parses typed parameters and calls
|
|
4
|
+
the ``run(...)`` logic function in the owning module. Output, exit codes, and
|
|
5
|
+
PID-file lifecycle are preserved from the former cm-* entry points; only the
|
|
6
|
+
argument-parsing layer changed (argparse -> cyclopts).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from cyclopts import ArgumentCollection, Group, Parameter
|
|
13
|
+
from cyclopts.validators import Number
|
|
14
|
+
|
|
15
|
+
from ccrecall import recent_chats as recent_chats_mod
|
|
16
|
+
from ccrecall import search_conversations as search_mod
|
|
17
|
+
from ccrecall import session_tail as session_tail_mod
|
|
18
|
+
from ccrecall import token_dashboard as token_dashboard_mod
|
|
19
|
+
from ccrecall.cli import app, backfill_app
|
|
20
|
+
from ccrecall.cli.context import DEFAULT_CLI_CONTEXT, CLIContextParam
|
|
21
|
+
from ccrecall.db import DEFAULT_DB_PATH, DEFAULT_PROJECTS_DIR
|
|
22
|
+
from ccrecall.embeddings import DEFAULT_EMBED_THREADS
|
|
23
|
+
from ccrecall.hooks import backfill_embeddings as backfill_embeddings_mod
|
|
24
|
+
from ccrecall.hooks import backfill_summaries as backfill_summaries_mod
|
|
25
|
+
from ccrecall.hooks import import_conversations as import_mod
|
|
26
|
+
from ccrecall.hooks import sync_current as sync_current_mod
|
|
27
|
+
from ccrecall.hooks import write_config as write_config_mod
|
|
28
|
+
|
|
29
|
+
# store_true flags carry no --no-<flag> negation, matching the former argparse.
|
|
30
|
+
_FLAG = Parameter(negative=[])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _exactly_one_query_or_status(arguments: ArgumentCollection) -> None:
|
|
34
|
+
"""Group validator: search needs exactly one of --query / --status.
|
|
35
|
+
|
|
36
|
+
Runs at parse time so the message renders in cyclopts' boxed error style
|
|
37
|
+
(and exits 2 via the entry-point wrapper), matching every other usage error.
|
|
38
|
+
search_conversations.run() keeps the same guard for direct (non-CLI) callers.
|
|
39
|
+
"""
|
|
40
|
+
# arg.tokens is non-empty only when that argument was supplied on the CLI.
|
|
41
|
+
provided = [arg for arg in arguments if arg.tokens]
|
|
42
|
+
if not provided:
|
|
43
|
+
raise ValueError("one of --query/-q or --status is required")
|
|
44
|
+
if len(provided) > 1:
|
|
45
|
+
raise ValueError("--query and --status are mutually exclusive")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Membership in this group (query + status below) is what triggers the
|
|
49
|
+
# exactly-one validator at parse time — the flags carry no logic themselves.
|
|
50
|
+
_SEARCH_MODE = Group("Search mode", validator=_exactly_one_query_or_status)
|
|
51
|
+
|
|
52
|
+
# Output format is global: the meta launcher's --json fills ctx.json_mode, which
|
|
53
|
+
# read commands map to their run()'s output_format kwd. list_sessions -> --list
|
|
54
|
+
# is renamed to avoid shadowing the list builtin; Parameter(name=...) keeps the
|
|
55
|
+
# user-facing flag intact.
|
|
56
|
+
|
|
57
|
+
# Shared flag types mirroring the former cm-* read tools.
|
|
58
|
+
_VERBOSE = Annotated[bool, _FLAG, Parameter(name=["--verbose", "-v"], help="Include files_modified and commits.")]
|
|
59
|
+
_NOTIFS = Annotated[
|
|
60
|
+
bool, _FLAG, Parameter(name=["--include-notifications"], help="Include task notification messages.")
|
|
61
|
+
]
|
|
62
|
+
_DB = Annotated[Path, Parameter(name=["--db"], help="Database path.")]
|
|
63
|
+
# Default for `tail -n`, sourced from session_tail so the two never drift.
|
|
64
|
+
_TAIL_DEFAULT_N = session_tail_mod.DEFAULT_TAIL_EVENTS
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@app.command(name="sync-current")
|
|
68
|
+
def cmd_sync_current(
|
|
69
|
+
*,
|
|
70
|
+
input_file: Annotated[
|
|
71
|
+
Path | None,
|
|
72
|
+
Parameter(name="--input-file", help="Read hook input from this file instead of stdin."),
|
|
73
|
+
] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Sync the current session into the memory DB (Stop-hook helper)."""
|
|
76
|
+
sync_current_mod.run(input_file)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command(name="import")
|
|
80
|
+
def cmd_import(
|
|
81
|
+
*,
|
|
82
|
+
db: Annotated[Path, Parameter(help="Database path.")] = DEFAULT_DB_PATH,
|
|
83
|
+
projects_dir: Annotated[Path, Parameter(help="Projects directory.")] = DEFAULT_PROJECTS_DIR,
|
|
84
|
+
project: Annotated[str | None, Parameter(help="Import only this project (by directory name).")] = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Import Claude Code conversations into the memory DB."""
|
|
87
|
+
import_mod.run(db=db, projects_dir=projects_dir, project=project)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command(name="stats")
|
|
91
|
+
def cmd_stats(
|
|
92
|
+
*,
|
|
93
|
+
db: Annotated[Path, Parameter(help="Database path.")] = DEFAULT_DB_PATH,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""Show memory database statistics."""
|
|
96
|
+
# Read-only DB-global counts: print_stats() shares no PID lifecycle with
|
|
97
|
+
# import.run(), so it can't disturb a concurrent background import.
|
|
98
|
+
import_mod.print_stats(db=db)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@backfill_app.command(name="summaries")
|
|
102
|
+
def cmd_backfill_summaries() -> None:
|
|
103
|
+
"""Backfill context summaries for branches that lack a current one."""
|
|
104
|
+
backfill_summaries_mod.run()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@backfill_app.command(name="embeddings")
|
|
108
|
+
def cmd_backfill_embeddings(
|
|
109
|
+
*,
|
|
110
|
+
status: Annotated[bool, _FLAG, Parameter(help="Report progress and exit without embedding (read-only).")] = False,
|
|
111
|
+
days: Annotated[
|
|
112
|
+
int | None,
|
|
113
|
+
Parameter(validator=Number(gte=1), help="Only embed branches ended within the last N days (>= 1)."),
|
|
114
|
+
] = None,
|
|
115
|
+
limit: Annotated[
|
|
116
|
+
int | None,
|
|
117
|
+
Parameter(validator=Number(gte=1), help="Stop after embedding at most N branches this run (>= 1)."),
|
|
118
|
+
] = None,
|
|
119
|
+
progress_every: Annotated[
|
|
120
|
+
int, Parameter(help="Print a progress line every N newly embedded branches.")
|
|
121
|
+
] = backfill_embeddings_mod.DEFAULT_PROGRESS_EVERY,
|
|
122
|
+
threads: Annotated[int, Parameter(help="Inference threads.")] = DEFAULT_EMBED_THREADS,
|
|
123
|
+
ctx: CLIContextParam = DEFAULT_CLI_CONTEXT,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Seed historical embeddings for active-leaf branch summaries (opt-in)."""
|
|
126
|
+
try:
|
|
127
|
+
code = backfill_embeddings_mod.run(
|
|
128
|
+
status=status,
|
|
129
|
+
json_mode=ctx.json_mode,
|
|
130
|
+
days=days,
|
|
131
|
+
limit=limit,
|
|
132
|
+
progress_every=progress_every,
|
|
133
|
+
threads=threads,
|
|
134
|
+
)
|
|
135
|
+
finally:
|
|
136
|
+
# Status is read-only: never disturb a concurrent backfill's PID marker.
|
|
137
|
+
if not status:
|
|
138
|
+
backfill_embeddings_mod.cleanup_pid()
|
|
139
|
+
raise SystemExit(code)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command(name="recent")
|
|
143
|
+
def cmd_recent(
|
|
144
|
+
*,
|
|
145
|
+
n: Annotated[
|
|
146
|
+
int,
|
|
147
|
+
Parameter(
|
|
148
|
+
name=["--n", "-n"],
|
|
149
|
+
validator=Number(gte=1, lte=recent_chats_mod.MAX_RECENT_SESSIONS),
|
|
150
|
+
help=f"Number of sessions (1-{recent_chats_mod.MAX_RECENT_SESSIONS}).",
|
|
151
|
+
),
|
|
152
|
+
] = 3,
|
|
153
|
+
sort_order: Annotated[Literal["desc", "asc"], Parameter(name=["--sort-order"], help="Sort order.")] = "desc",
|
|
154
|
+
before: Annotated[str | None, Parameter(help="Sessions before this datetime (ISO).")] = None,
|
|
155
|
+
after: Annotated[str | None, Parameter(help="Sessions after this datetime (ISO).")] = None,
|
|
156
|
+
session: Annotated[str | None, Parameter(help="Filter by session UUID (prefix match).")] = None,
|
|
157
|
+
project: Annotated[str | None, Parameter(help="Filter by project name(s), comma-separated.")] = None,
|
|
158
|
+
path: Annotated[str | None, Parameter(help="Filter by cwd substring (e.g. worktree name).")] = None,
|
|
159
|
+
verbose: _VERBOSE = False,
|
|
160
|
+
include_notifications: _NOTIFS = False,
|
|
161
|
+
db: _DB = DEFAULT_DB_PATH,
|
|
162
|
+
ctx: CLIContextParam = DEFAULT_CLI_CONTEXT,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""List recent conversation sessions."""
|
|
165
|
+
recent_chats_mod.run(
|
|
166
|
+
n=n,
|
|
167
|
+
sort_order=sort_order,
|
|
168
|
+
before=before,
|
|
169
|
+
after=after,
|
|
170
|
+
session=session,
|
|
171
|
+
project=project,
|
|
172
|
+
path=path,
|
|
173
|
+
output_format=ctx.output_format,
|
|
174
|
+
verbose=verbose,
|
|
175
|
+
include_notifications=include_notifications,
|
|
176
|
+
db=db,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command(name="search")
|
|
181
|
+
def cmd_search(
|
|
182
|
+
*,
|
|
183
|
+
query: Annotated[str | None, Parameter(name=["--query", "-q"], group=_SEARCH_MODE, help="Search keywords.")] = None,
|
|
184
|
+
status: Annotated[bool, _FLAG, Parameter(group=_SEARCH_MODE, help="Print diagnostic status and exit.")] = False,
|
|
185
|
+
keyword_only: Annotated[bool, _FLAG, Parameter(help="Skip embedding; keyword search only.")] = False,
|
|
186
|
+
max_results: Annotated[
|
|
187
|
+
int,
|
|
188
|
+
Parameter(
|
|
189
|
+
name=["--max-results"],
|
|
190
|
+
validator=Number(gte=1, lte=search_mod.MAX_SEARCH_RESULTS),
|
|
191
|
+
help=f"Max sessions (1-{search_mod.MAX_SEARCH_RESULTS}).",
|
|
192
|
+
),
|
|
193
|
+
] = 5,
|
|
194
|
+
session: Annotated[str | None, Parameter(help="Filter by session UUID (prefix match).")] = None,
|
|
195
|
+
project: Annotated[str | None, Parameter(help="Filter by project name(s), comma-separated.")] = None,
|
|
196
|
+
path: Annotated[str | None, Parameter(help="Filter by cwd substring (e.g. worktree name).")] = None,
|
|
197
|
+
verbose: _VERBOSE = False,
|
|
198
|
+
include_notifications: _NOTIFS = False,
|
|
199
|
+
db: _DB = DEFAULT_DB_PATH,
|
|
200
|
+
ctx: CLIContextParam = DEFAULT_CLI_CONTEXT,
|
|
201
|
+
) -> None:
|
|
202
|
+
"""Search conversation sessions (keyword + vector fusion)."""
|
|
203
|
+
search_mod.run(
|
|
204
|
+
query=query,
|
|
205
|
+
status=status,
|
|
206
|
+
keyword_only=keyword_only,
|
|
207
|
+
max_results=max_results,
|
|
208
|
+
session=session,
|
|
209
|
+
project=project,
|
|
210
|
+
path=path,
|
|
211
|
+
output_format=ctx.output_format,
|
|
212
|
+
verbose=verbose,
|
|
213
|
+
include_notifications=include_notifications,
|
|
214
|
+
db=db,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@app.command(name="tail")
|
|
219
|
+
def cmd_tail(
|
|
220
|
+
selector: Annotated[str | None, Parameter(help="Session id or substring to target.")] = None,
|
|
221
|
+
*,
|
|
222
|
+
list_sessions: Annotated[bool, _FLAG, Parameter(name=["--list"], help="List sessions and exit.")] = False,
|
|
223
|
+
cwd: Annotated[str | None, Parameter(name=["--cwd"], help="Derive project dir from this path.")] = None,
|
|
224
|
+
n: Annotated[int, Parameter(name=["-n"], help="Number of tail events to show.")] = _TAIL_DEFAULT_N,
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Print the tail of a prior session's transcript for fast resume."""
|
|
227
|
+
raise SystemExit(session_tail_mod.run(selector, list_sessions=list_sessions, cwd=cwd, n=n))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.command(name="tokens")
|
|
231
|
+
def cmd_tokens() -> None:
|
|
232
|
+
"""Ingest token data, refresh the dashboard, and print a slim summary."""
|
|
233
|
+
token_dashboard_mod.run()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@app.command(name="write-config")
|
|
237
|
+
def cmd_write_config(
|
|
238
|
+
*,
|
|
239
|
+
defaults: Annotated[bool, _FLAG, Parameter(help="Write recommended defaults without explicit flags.")] = False,
|
|
240
|
+
auto_inject_context: Annotated[
|
|
241
|
+
bool | None, Parameter(name=["--auto-inject-context"], help="Enable session context injection on startup.")
|
|
242
|
+
] = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Write or update the ccrecall config from onboarding choices."""
|
|
245
|
+
write_config_mod.run(defaults=defaults, auto_inject_context=auto_inject_context)
|
ccrecall/cli/context.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""CLI context object — frozen dataclass carrying per-invocation global options."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
from cyclopts import Parameter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class CLIContext:
|
|
11
|
+
"""Immutable global options for a single CLI invocation.
|
|
12
|
+
|
|
13
|
+
Built by the meta launcher from parsed global flags and injected into every
|
|
14
|
+
command that declares a ``ctx`` parameter via ``bound.arguments["ctx"]``.
|
|
15
|
+
Commands map it onto their owning ``run(...)`` kwargs, so ``run`` stays the
|
|
16
|
+
stable callable API while the CLI keeps one global ``--json`` surface.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
json_mode: bool = False
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def output_format(self) -> str:
|
|
23
|
+
"""The ``output_format`` string the markdown/JSON-aware ``run()`` funcs expect."""
|
|
24
|
+
return "json" if self.json_mode else "markdown"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# parse=False: the launcher injects this, cyclopts never fills it from tokens,
|
|
28
|
+
# and it stays out of every command's --help.
|
|
29
|
+
CLIContextParam = Annotated[CLIContext, Parameter(parse=False)]
|
|
30
|
+
|
|
31
|
+
DEFAULT_CLI_CONTEXT = CLIContext()
|
ccrecall/content.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Message content extraction and tool detection utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
# Commit messages are stored truncated — they're for at-a-glance session context,
|
|
7
|
+
# not full reconstruction.
|
|
8
|
+
MAX_COMMIT_MESSAGE_LEN = 100
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sanitize_fts_term(term: str) -> str:
|
|
12
|
+
"""Remove FTS special characters from search term.
|
|
13
|
+
|
|
14
|
+
Strips characters that are FTS operators or special syntax:
|
|
15
|
+
quotes, parentheses, asterisks, and FTS keywords.
|
|
16
|
+
Hyphens are replaced with spaces so hyphenated identifiers
|
|
17
|
+
(e.g. 'pytest-mock') match their FTS tokens correctly — the
|
|
18
|
+
unicode61 tokenizer splits on hyphens, so 'pytest-mock' indexes
|
|
19
|
+
as two tokens ('pytest', 'mock'). Stripping hyphens entirely
|
|
20
|
+
would produce 'pytestmock', which matches nothing.
|
|
21
|
+
Leading hyphens (FTS NOT shorthand) become harmless whitespace.
|
|
22
|
+
"""
|
|
23
|
+
# Replace hyphens with spaces (handles both identifier separators
|
|
24
|
+
# and leading NOT-operator hyphens)
|
|
25
|
+
sanitized = term.replace("-", " ")
|
|
26
|
+
# Remove remaining FTS operators: quotes, parens, asterisk, caret
|
|
27
|
+
sanitized = re.sub(r'["\(\)*^]', "", sanitized)
|
|
28
|
+
# Remove FTS keywords: NEAR, AND, OR, NOT (case-insensitive)
|
|
29
|
+
sanitized = re.sub(r"\b(NEAR|AND|OR|NOT)\b", "", sanitized, flags=re.IGNORECASE)
|
|
30
|
+
# Collapse whitespace and strip
|
|
31
|
+
sanitized = re.sub(r"\s+", " ", sanitized).strip()
|
|
32
|
+
return sanitized
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_text_content(content) -> tuple[str, bool, bool, str | None]:
|
|
36
|
+
"""
|
|
37
|
+
Extract text from message content.
|
|
38
|
+
Returns: (text, has_tool_use, has_thinking, tool_summary_json)
|
|
39
|
+
|
|
40
|
+
tool_summary_json is a JSON string like '{"Bash":3,"Read":2}' or None.
|
|
41
|
+
Tool use markers are NOT materialized into text.
|
|
42
|
+
"""
|
|
43
|
+
has_tool_use = False
|
|
44
|
+
has_thinking = False
|
|
45
|
+
tool_counts: dict[str, int] = {}
|
|
46
|
+
|
|
47
|
+
if isinstance(content, str):
|
|
48
|
+
# Clean up command artifacts
|
|
49
|
+
text = re.sub(r"<command-name>.*?</command-name>", "", content, flags=re.DOTALL)
|
|
50
|
+
text = re.sub(r"<command-message>.*?</command-message>", "", text, flags=re.DOTALL)
|
|
51
|
+
text = re.sub(r"<command-args>.*?</command-args>", "", text, flags=re.DOTALL)
|
|
52
|
+
text = re.sub(
|
|
53
|
+
r"<local-command-stdout>.*?</local-command-stdout>",
|
|
54
|
+
"",
|
|
55
|
+
text,
|
|
56
|
+
flags=re.DOTALL,
|
|
57
|
+
)
|
|
58
|
+
text = re.sub(r"<channel\b[^>]*>\n?([\s\S]*?)\n?</channel>", r"\1", text, flags=re.DOTALL)
|
|
59
|
+
return text.strip(), False, False, None
|
|
60
|
+
|
|
61
|
+
if isinstance(content, list):
|
|
62
|
+
texts = []
|
|
63
|
+
for item in content:
|
|
64
|
+
if isinstance(item, dict):
|
|
65
|
+
item_type = item.get("type", "")
|
|
66
|
+
if item_type == "text":
|
|
67
|
+
texts.append(item.get("text", ""))
|
|
68
|
+
elif item_type == "tool_use":
|
|
69
|
+
has_tool_use = True
|
|
70
|
+
tool_name = item.get("name", "")
|
|
71
|
+
if tool_name:
|
|
72
|
+
tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
|
|
73
|
+
elif item_type == "thinking":
|
|
74
|
+
has_thinking = True
|
|
75
|
+
tool_summary = json.dumps(tool_counts) if tool_counts else None
|
|
76
|
+
return "\n".join(texts).strip(), has_tool_use, has_thinking, tool_summary
|
|
77
|
+
|
|
78
|
+
return "", False, False, None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_origin(entry: dict) -> str | None:
|
|
82
|
+
"""Extract clean platform name from origin.server (e.g. 'telegram' from 'plugin:telegram:telegram')."""
|
|
83
|
+
origin = entry.get("origin")
|
|
84
|
+
if not origin or not isinstance(origin, dict):
|
|
85
|
+
return None
|
|
86
|
+
server = origin.get("server") or ""
|
|
87
|
+
if not server:
|
|
88
|
+
return None
|
|
89
|
+
# Pattern: "plugin:telegram:telegram" -> "telegram"
|
|
90
|
+
parts = server.split(":")
|
|
91
|
+
if len(parts) >= 2 and parts[1]:
|
|
92
|
+
return parts[1]
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_plain_text(content) -> str | None:
|
|
97
|
+
"""Join text blocks (or a bare string) into stripped plain text; None if neither shape."""
|
|
98
|
+
if isinstance(content, list):
|
|
99
|
+
texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
|
|
100
|
+
return "\n".join(texts).strip()
|
|
101
|
+
if isinstance(content, str):
|
|
102
|
+
return content.strip()
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_task_notification(content) -> bool:
|
|
107
|
+
"""Check if content is a task-notification message (subagent result)."""
|
|
108
|
+
text = extract_plain_text(content)
|
|
109
|
+
return text is not None and text.startswith("<task-notification>")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_teammate_message(content) -> bool:
|
|
113
|
+
"""Detect teammate coordination messages (team reports, idle notifications, shutdown)."""
|
|
114
|
+
text = extract_plain_text(content)
|
|
115
|
+
return text is not None and text.startswith("<teammate-message")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_tool_result(content) -> bool:
|
|
119
|
+
"""Check if content is a tool result (not a real user message)."""
|
|
120
|
+
if isinstance(content, list) and content:
|
|
121
|
+
first = content[0]
|
|
122
|
+
if isinstance(first, dict) and first.get("type") == "tool_result":
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def extract_files_modified(content) -> list[str]:
|
|
128
|
+
"""Extract file paths from Edit/Write/MultiEdit tool uses."""
|
|
129
|
+
files = []
|
|
130
|
+
if isinstance(content, list):
|
|
131
|
+
for item in content:
|
|
132
|
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
133
|
+
name = item.get("name", "")
|
|
134
|
+
inp = item.get("input", {})
|
|
135
|
+
if name in ("Edit", "Write", "MultiEdit") and "file_path" in inp:
|
|
136
|
+
files.append(inp["file_path"])
|
|
137
|
+
return files
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def extract_commits(content) -> list[str]:
|
|
141
|
+
"""Extract git commit messages from Bash tool uses."""
|
|
142
|
+
commits = []
|
|
143
|
+
if isinstance(content, list):
|
|
144
|
+
for item in content:
|
|
145
|
+
if isinstance(item, dict) and item.get("type") == "tool_use":
|
|
146
|
+
if item.get("name") == "Bash":
|
|
147
|
+
cmd = item.get("input", {}).get("command", "")
|
|
148
|
+
if "git commit" in cmd:
|
|
149
|
+
match = re.search(r'-m\s+["\']([^"\']+)["\']', cmd)
|
|
150
|
+
if match:
|
|
151
|
+
commits.append(match.group(1)[:MAX_COMMIT_MESSAGE_LEN])
|
|
152
|
+
return commits
|