claude-smart 0.2.31 → 0.2.32
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.
- package/README.md +2 -2
- package/bin/claude-smart.js +172 -18
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/dashboard/app/api/config/route.ts +11 -2
- package/plugin/dashboard/app/api/health/route.ts +45 -6
- package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
- package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
- package/plugin/dashboard/app/configure/env/page.tsx +36 -31
- package/plugin/dashboard/app/configure/layout.tsx +1 -1
- package/plugin/dashboard/app/configure/server/page.tsx +8 -14
- package/plugin/dashboard/app/dashboard/page.tsx +311 -115
- package/plugin/dashboard/app/globals.css +80 -66
- package/plugin/dashboard/app/layout.tsx +13 -10
- package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
- package/plugin/dashboard/app/preferences/page.tsx +154 -54
- package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
- package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
- package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
- package/plugin/dashboard/app/sessions/page.tsx +14 -10
- package/plugin/dashboard/app/skills/page.tsx +175 -56
- package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
- package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
- package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
- package/plugin/dashboard/components/common/empty-state.tsx +4 -2
- package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
- package/plugin/dashboard/components/common/page-header.tsx +5 -3
- package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
- package/plugin/dashboard/components/common/stat-card.tsx +9 -5
- package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
- package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
- package/plugin/dashboard/components/ui/input.tsx +1 -0
- package/plugin/dashboard/hooks/use-settings.tsx +30 -61
- package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
- package/plugin/dashboard/lib/config-file.ts +2 -0
- package/plugin/dashboard/lib/reflexio-client.ts +23 -48
- package/plugin/dashboard/lib/session-reader.ts +222 -6
- package/plugin/dashboard/lib/types.ts +20 -1
- package/plugin/dashboard/package-lock.json +70 -95
- package/plugin/dashboard/package.json +5 -2
- package/plugin/pyproject.toml +1 -1
- package/plugin/scripts/_lib.sh +61 -0
- package/plugin/scripts/backend-service.sh +13 -2
- package/plugin/scripts/cli.sh +1 -0
- package/plugin/scripts/codex-hook.js +100 -3
- package/plugin/scripts/dashboard-service.sh +95 -19
- package/plugin/scripts/hook_entry.sh +13 -7
- package/plugin/scripts/smart-install.sh +18 -13
- package/plugin/src/claude_smart/cli.py +86 -20
- package/plugin/src/claude_smart/context_format.py +244 -6
- package/plugin/src/claude_smart/context_inject.py +8 -1
- package/plugin/src/claude_smart/cs_cite.py +186 -34
- package/plugin/src/claude_smart/env_config.py +102 -0
- package/plugin/src/claude_smart/events/stop.py +32 -6
- package/plugin/src/claude_smart/internal_call.py +30 -0
- package/plugin/src/claude_smart/publish.py +5 -0
- package/plugin/src/claude_smart/reflexio_adapter.py +102 -26
- package/plugin/uv.lock +1 -1
|
@@ -7,7 +7,7 @@ real id (``[cs:s1-1a2b]`` for the first skill whose
|
|
|
7
7
|
second preference). The injected instruction asks the assistant to end
|
|
8
8
|
impactful replies with a marker like::
|
|
9
9
|
|
|
10
|
-
✨
|
|
10
|
+
✨ claude-smart rule applied: [git safety](http://localhost:3001/rules/s1-123)
|
|
11
11
|
|
|
12
12
|
The Stop hook later scans the assistant text for those markers and resolves
|
|
13
13
|
the ids against a per-session registry persisted at
|
|
@@ -26,18 +26,24 @@ This module holds:
|
|
|
26
26
|
- ``rank_id``: ``p{n}-{fp}`` / ``s{n}-{fp}`` tag for a given
|
|
27
27
|
(kind, rank, real_id) tuple. Fingerprint is omitted when no real id
|
|
28
28
|
is available. ``p`` is preference, ``s`` is skill.
|
|
29
|
-
- ``
|
|
30
|
-
so the assistant knows when and how to emit the citation marker.
|
|
29
|
+
- ``citation_instruction(mode)``: the trailer text appended to injected
|
|
30
|
+
context so the assistant knows when and how to emit the citation marker.
|
|
31
|
+
``mode`` is read from the ``CLAUDE_SMART_CITATIONS`` env var by the
|
|
32
|
+
caller; ``"off"`` disables the instruction, while all other values enable
|
|
33
|
+
the compact marker instruction for backward compatibility.
|
|
34
|
+
- ``CITATION_INSTRUCTION``: the compact enabled instruction string, kept as
|
|
35
|
+
a module-level constant for backward-compatible imports.
|
|
31
36
|
"""
|
|
32
37
|
|
|
33
38
|
from __future__ import annotations
|
|
34
39
|
|
|
35
40
|
import re
|
|
36
41
|
from typing import Any
|
|
42
|
+
from urllib.parse import unquote, urlparse
|
|
37
43
|
|
|
38
44
|
_FINGERPRINT_LEN = 4
|
|
39
45
|
|
|
40
|
-
_ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?"
|
|
46
|
+
_ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?" # noqa: S105
|
|
41
47
|
_ID_SEP = r"[,\s]+"
|
|
42
48
|
_CLEAN_ID_RE = re.compile(r"^(?i:cs:)?((?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?)$")
|
|
43
49
|
_SPLIT_RE = re.compile(_ID_SEP)
|
|
@@ -45,28 +51,97 @@ _TEXT_CITATION_LINE_RE = re.compile(
|
|
|
45
51
|
r"(?im)^\s*✨\s+\d+\s+claude-smart learning(?:s)? applied\s+"
|
|
46
52
|
r"\[cs:(?P<ids>[^\]]+)\]\s*$"
|
|
47
53
|
)
|
|
54
|
+
_APPLIED_LINK_LINE_RE = re.compile(
|
|
55
|
+
r"(?im)^\s*✨\s+(?:Applied|claude-smart rules? applied):\s+(?P<body>.+?)\s*$"
|
|
56
|
+
)
|
|
57
|
+
_MARKDOWN_LINK_RE = re.compile(r"\[[^\]]+\]\((?P<url>[^)]+)\)")
|
|
58
|
+
_OSC8_URL_RE = re.compile(
|
|
59
|
+
r"\x1b\]8;[^\x07\x1b]*;(?P<url>[^\x07\x1b]+)(?:\x07|\x1b\\)"
|
|
60
|
+
)
|
|
61
|
+
_RAW_DASHBOARD_URL_RE = re.compile(
|
|
62
|
+
r"(?P<url>(?:https?://[^\s),\x1b\\]+)?/"
|
|
63
|
+
r"(?:skills/(?:project|shared)/[^\s),\x1b\\]+|"
|
|
64
|
+
r"preferences/(?:project/)?[^\s),\x1b\\]+|"
|
|
65
|
+
r"rules/[^\s),\x1b\\]+))"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_COMPACT_INTRO = (
|
|
69
|
+
"_If you use any listed `[cs:…]` item to answer and it materially changes "
|
|
70
|
+
"your answer, you must end with one final marker line. Skip the marker "
|
|
71
|
+
"only when no listed item affected the answer."
|
|
72
|
+
)
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
"Never emit a standalone wrapper like `✨s1-ab12✨` or `✨abc123✨`; "
|
|
62
|
-
"those are not claude-smart citations and cannot be resolved. "
|
|
63
|
-
"Default is to skip. If an item is merely on-topic, confirms what you "
|
|
64
|
-
"already planned, or your reply would read the same without it, do not "
|
|
65
|
-
"cite — end the turn normally with your reply. When unsure, skip. Do "
|
|
66
|
-
"not add any other text, tool calls, or role markers after the final "
|
|
67
|
-
"citation line._"
|
|
74
|
+
_MARKDOWN_MARKER_PARAGRAPH = (
|
|
75
|
+
"Use human-readable linked titles, not raw ids. Format for one item: "
|
|
76
|
+
"`✨ claude-smart rule applied: "
|
|
77
|
+
"[verify process state](http://localhost:3001/rules/s1-123)`. "
|
|
78
|
+
"For multiple items: "
|
|
79
|
+
"`✨ claude-smart rule applied: "
|
|
80
|
+
"[git safety](http://localhost:3001/rules/s1-123) | "
|
|
81
|
+
"[brief answer preference](http://localhost:3001/rules/p1-pref)`. "
|
|
82
|
+
"Separate multiple linked memories with the visible ` | ` separator. "
|
|
83
|
+
"Use the dashboard URL shown beside each cited item; do not invent URLs. "
|
|
84
|
+
"Do not include `[cs:…]` ids in the marker line. Never use the old "
|
|
85
|
+
"`✨ 1 claude-smart learning applied [cs:...]` marker format._"
|
|
68
86
|
)
|
|
69
87
|
|
|
88
|
+
_OSC8_EXAMPLE_ONE = (
|
|
89
|
+
"✨ claude-smart rule applied: "
|
|
90
|
+
"\x1b]8;;http://localhost:3001/rules/s1-123\x1b\\"
|
|
91
|
+
"verify process state"
|
|
92
|
+
"\x1b]8;;\x1b\\"
|
|
93
|
+
)
|
|
94
|
+
_OSC8_EXAMPLE_MULTI = (
|
|
95
|
+
"✨ claude-smart rule applied: "
|
|
96
|
+
"\x1b]8;;http://localhost:3001/rules/s1-123\x1b\\"
|
|
97
|
+
"git safety"
|
|
98
|
+
"\x1b]8;;\x1b\\"
|
|
99
|
+
" | "
|
|
100
|
+
"\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\"
|
|
101
|
+
"brief answer preference"
|
|
102
|
+
"\x1b]8;;\x1b\\"
|
|
103
|
+
)
|
|
104
|
+
_OSC8_MARKER_PARAGRAPH = (
|
|
105
|
+
"Use human-readable OSC 8 terminal hyperlinks, not raw ids or visible "
|
|
106
|
+
"URLs. Format for one item: "
|
|
107
|
+
f"`{_OSC8_EXAMPLE_ONE}`. For multiple items: "
|
|
108
|
+
f"`{_OSC8_EXAMPLE_MULTI}`. Use the dashboard URL shown beside each cited "
|
|
109
|
+
"item; do not invent URLs. Separate multiple linked memories with the "
|
|
110
|
+
"visible ` | ` separator. If OSC 8 is unavailable, use markdown links. "
|
|
111
|
+
"Do not include `[cs:…]` ids in the marker line. Do not omit the marker "
|
|
112
|
+
"after using listed memory._"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
CITATION_MODES = ("on", "auto", "marker-only", "off")
|
|
116
|
+
LINK_STYLES = ("markdown", "osc8")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def citation_instruction(mode: str, link_style: str = "markdown") -> str:
|
|
120
|
+
"""Return the citation prompt for ``mode``.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
mode: ``"off"`` returns an empty string so no instruction is
|
|
124
|
+
injected. Any other value, including legacy ``"auto"`` and
|
|
125
|
+
``"marker-only"``, returns the compact enabled instruction.
|
|
126
|
+
Unknown values stay enabled — env-var typos must not break
|
|
127
|
+
injection.
|
|
128
|
+
link_style: ``"markdown"`` for ordinary markdown links, or ``"osc8"``
|
|
129
|
+
for terminal-native hyperlinks. Unknown values fall back to
|
|
130
|
+
``"markdown"``.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
str: The instruction text, or ``""`` for ``"off"``.
|
|
134
|
+
"""
|
|
135
|
+
if mode == "off":
|
|
136
|
+
return ""
|
|
137
|
+
marker_paragraph = (
|
|
138
|
+
_OSC8_MARKER_PARAGRAPH if link_style == "osc8" else _MARKDOWN_MARKER_PARAGRAPH
|
|
139
|
+
)
|
|
140
|
+
return f"{_COMPACT_INTRO}\n\n{marker_paragraph}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
CITATION_INSTRUCTION = citation_instruction("on")
|
|
144
|
+
|
|
70
145
|
|
|
71
146
|
def _fingerprint(real_id: Any) -> str:
|
|
72
147
|
"""Return the first ``_FINGERPRINT_LEN`` alphanumeric chars of ``real_id``.
|
|
@@ -128,21 +203,98 @@ def rank_id(kind: str, rank: int, real_id: Any = None) -> str:
|
|
|
128
203
|
def parse_text_citations(text: str) -> list[str]:
|
|
129
204
|
"""Extract Codex text-only citation ids from a final learning marker line.
|
|
130
205
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
206
|
+
Supports the legacy id marker::
|
|
207
|
+
|
|
208
|
+
✨ 1 claude-smart learning applied [cs:s1-ab12]
|
|
209
|
+
|
|
210
|
+
and the newer human-readable dashboard-link marker::
|
|
211
|
+
|
|
212
|
+
✨ claude-smart rule applied: [git safety](http://localhost:3001/rules/s1-123)
|
|
213
|
+
|
|
214
|
+
Ordinary references to injected ``[cs:...]`` ids or dashboard URLs inside
|
|
215
|
+
the answer do not count as citations; they must appear on a final marker
|
|
216
|
+
line. When multiple matching lines exist, the last one wins because the
|
|
135
217
|
instruction requires the citation marker to be final.
|
|
136
218
|
"""
|
|
137
|
-
|
|
219
|
+
old_matches = [
|
|
220
|
+
(m.start(), "ids", m.group("ids"))
|
|
221
|
+
for m in _TEXT_CITATION_LINE_RE.finditer(text or "")
|
|
222
|
+
]
|
|
223
|
+
new_matches = [
|
|
224
|
+
(m.start(), "links", m.group("body"))
|
|
225
|
+
for m in _APPLIED_LINK_LINE_RE.finditer(text or "")
|
|
226
|
+
]
|
|
227
|
+
matches = old_matches + new_matches
|
|
138
228
|
if not matches:
|
|
139
229
|
return []
|
|
140
|
-
|
|
230
|
+
_, kind, value = max(matches, key=lambda item: item[0])
|
|
231
|
+
if kind == "ids":
|
|
232
|
+
return _parse_id_tokens(value)
|
|
233
|
+
return _parse_dashboard_link_tokens(value)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def strip_marker_lines(text: str) -> str:
|
|
237
|
+
"""Remove any ``✨ N claude-smart learning(s) applied [cs:…]`` lines.
|
|
238
|
+
|
|
239
|
+
Used by the Stop hook when ``CLAUDE_SMART_CITATIONS=off`` to scrub
|
|
240
|
+
any marker the assistant emitted from a cached prompt fragment that
|
|
241
|
+
still contained the citation instruction. Returns ``text`` unchanged
|
|
242
|
+
when no marker line is present.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
text: Assistant message text.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
str: ``text`` with marker lines removed, trailing blank lines
|
|
249
|
+
trimmed. ``""`` when ``text`` is falsy.
|
|
250
|
+
"""
|
|
251
|
+
if not text:
|
|
252
|
+
return text or ""
|
|
253
|
+
cleaned = _TEXT_CITATION_LINE_RE.sub("", text)
|
|
254
|
+
cleaned = _APPLIED_LINK_LINE_RE.sub("", cleaned)
|
|
255
|
+
return cleaned.rstrip("\n")
|
|
141
256
|
|
|
142
257
|
|
|
143
258
|
def _parse_id_tokens(raw_ids: str) -> list[str]:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
259
|
+
return [
|
|
260
|
+
clean.group(1).lower()
|
|
261
|
+
for tok in _SPLIT_RE.split(raw_ids.strip())
|
|
262
|
+
if (clean := _CLEAN_ID_RE.match(tok))
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_dashboard_link_tokens(raw: str) -> list[str]:
|
|
267
|
+
tokens: list[str] = []
|
|
268
|
+
seen: set[str] = set()
|
|
269
|
+
matches = [
|
|
270
|
+
(m.start(), m.group("url"))
|
|
271
|
+
for regex in (_OSC8_URL_RE, _MARKDOWN_LINK_RE, _RAW_DASHBOARD_URL_RE)
|
|
272
|
+
for m in regex.finditer(raw)
|
|
273
|
+
]
|
|
274
|
+
for _, url in sorted(matches, key=lambda item: item[0]):
|
|
275
|
+
if (token := dashboard_url_token(url)) and token not in seen:
|
|
276
|
+
seen.add(token)
|
|
277
|
+
tokens.append(token)
|
|
278
|
+
return tokens
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def dashboard_url_token(url: str) -> str:
|
|
282
|
+
"""Return an internal resolver token for a dashboard detail URL.
|
|
283
|
+
|
|
284
|
+
The token is consumed by ``events.stop._resolve_cited_items``. It is not
|
|
285
|
+
shown to users.
|
|
286
|
+
"""
|
|
287
|
+
parsed = urlparse(url)
|
|
288
|
+
path = parsed.path if parsed.scheme else url.split("?", 1)[0].split("#", 1)[0]
|
|
289
|
+
parts = [unquote(part) for part in path.strip("/").split("/") if part]
|
|
290
|
+
if len(parts) == 3 and parts[0] == "skills" and parts[1] in {"project", "shared"}:
|
|
291
|
+
source_kind = "user_playbook" if parts[1] == "project" else "agent_playbook"
|
|
292
|
+
return f"route:playbook:{source_kind}:{parts[2]}"
|
|
293
|
+
if len(parts) == 2 and parts[0] == "preferences":
|
|
294
|
+
return f"route:profile:profile:{parts[1]}"
|
|
295
|
+
if len(parts) == 3 and parts[0] == "preferences" and parts[1] == "project":
|
|
296
|
+
return f"route:profile:profile:{parts[2]}"
|
|
297
|
+
if len(parts) == 2 and parts[0] == "rules":
|
|
298
|
+
clean = _CLEAN_ID_RE.match(parts[1])
|
|
299
|
+
return clean.group(1).lower() if clean else ""
|
|
300
|
+
return ""
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Shared ``~/.reflexio/.env`` helpers for claude-smart."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
REFLEXIO_ENV_PATH = Path.home() / ".reflexio" / ".env"
|
|
9
|
+
MANAGED_REFLEXIO_URL = "https://www.reflexio.ai/"
|
|
10
|
+
REFLEXIO_URL_ENV = "REFLEXIO_URL"
|
|
11
|
+
REFLEXIO_API_KEY_ENV = "REFLEXIO_API_KEY"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_env_line(line: str) -> tuple[str, str] | None:
|
|
15
|
+
"""Parse a simple dotenv ``KEY=value`` line.
|
|
16
|
+
|
|
17
|
+
This intentionally ignores comments, blank lines, and shell features. The
|
|
18
|
+
Reflexio env writer emits plain quoted assignments, which is all
|
|
19
|
+
claude-smart needs here.
|
|
20
|
+
"""
|
|
21
|
+
stripped = line.strip()
|
|
22
|
+
if not stripped or stripped.startswith("#"):
|
|
23
|
+
return None
|
|
24
|
+
if stripped.startswith("export "):
|
|
25
|
+
stripped = stripped[len("export ") :].lstrip()
|
|
26
|
+
key, sep, raw_value = stripped.partition("=")
|
|
27
|
+
if not sep:
|
|
28
|
+
return None
|
|
29
|
+
key = key.strip()
|
|
30
|
+
if not key or not key.replace("_", "").isalnum() or key[0].isdigit():
|
|
31
|
+
return None
|
|
32
|
+
value = raw_value.strip()
|
|
33
|
+
if len(value) >= 2 and (
|
|
34
|
+
(value[0] == value[-1] == '"') or (value[0] == value[-1] == "'")
|
|
35
|
+
):
|
|
36
|
+
value = value[1:-1]
|
|
37
|
+
return key, value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_reflexio_env(path: Path | None = None) -> None:
|
|
41
|
+
"""Load ``~/.reflexio/.env`` into ``os.environ`` without overriding values."""
|
|
42
|
+
path = path or REFLEXIO_ENV_PATH
|
|
43
|
+
try:
|
|
44
|
+
text = path.read_text()
|
|
45
|
+
except OSError:
|
|
46
|
+
return
|
|
47
|
+
for line in text.splitlines():
|
|
48
|
+
parsed = parse_env_line(line)
|
|
49
|
+
if parsed is None:
|
|
50
|
+
continue
|
|
51
|
+
key, value = parsed
|
|
52
|
+
os.environ.setdefault(key, value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_env_vars(path: Path, values: dict[str, str]) -> list[str]:
|
|
56
|
+
"""Upsert dotenv keys while preserving comments and unrelated entries."""
|
|
57
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
try:
|
|
59
|
+
existing = path.read_text()
|
|
60
|
+
except OSError:
|
|
61
|
+
existing = ""
|
|
62
|
+
|
|
63
|
+
lines = existing.splitlines()
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
for line in lines:
|
|
67
|
+
parsed = parse_env_line(line)
|
|
68
|
+
if parsed is None:
|
|
69
|
+
out.append(line)
|
|
70
|
+
continue
|
|
71
|
+
key, _old = parsed
|
|
72
|
+
if key in values:
|
|
73
|
+
out.append(f'{key}="{_escape_env_value(values[key])}"')
|
|
74
|
+
seen.add(key)
|
|
75
|
+
else:
|
|
76
|
+
out.append(line)
|
|
77
|
+
|
|
78
|
+
added: list[str] = []
|
|
79
|
+
for key, value in values.items():
|
|
80
|
+
if key in seen:
|
|
81
|
+
continue
|
|
82
|
+
out.append(f'{key}="{_escape_env_value(value)}"')
|
|
83
|
+
added.append(key)
|
|
84
|
+
|
|
85
|
+
content = "\n".join(out)
|
|
86
|
+
path.write_text(content + ("\n" if content else ""), encoding="utf-8")
|
|
87
|
+
path.chmod(0o600)
|
|
88
|
+
return added
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def mask_secret(value: str) -> str:
|
|
92
|
+
"""Return a display-safe API key preview."""
|
|
93
|
+
if not value:
|
|
94
|
+
return ""
|
|
95
|
+
if len(value) <= 8:
|
|
96
|
+
return "*" * len(value)
|
|
97
|
+
prefix = value[:5] if "-" in value[:8] else value[:4]
|
|
98
|
+
return f"{prefix}****{value[-4:]}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _escape_env_value(value: str) -> str:
|
|
102
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import time
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any
|
|
@@ -289,7 +290,7 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
|
|
|
289
290
|
for cid in cited_ids:
|
|
290
291
|
if cid in seen:
|
|
291
292
|
continue
|
|
292
|
-
entry = registry
|
|
293
|
+
entry = _registry_entry_for_citation(registry, cid)
|
|
293
294
|
if not entry:
|
|
294
295
|
continue
|
|
295
296
|
seen.add(cid)
|
|
@@ -308,6 +309,27 @@ def _resolve_cited_items(session_id: str, cited_ids: list[str]) -> list[dict[str
|
|
|
308
309
|
return resolved
|
|
309
310
|
|
|
310
311
|
|
|
312
|
+
def _registry_entry_for_citation(
|
|
313
|
+
registry: dict[str, dict[str, Any]], citation: str
|
|
314
|
+
) -> dict[str, Any] | None:
|
|
315
|
+
"""Resolve a legacy rank id or a dashboard-route citation token."""
|
|
316
|
+
if not citation.startswith("route:"):
|
|
317
|
+
return registry.get(citation)
|
|
318
|
+
try:
|
|
319
|
+
_, kind, source_kind, real_id = citation.split(":", 3)
|
|
320
|
+
except ValueError:
|
|
321
|
+
return None
|
|
322
|
+
for entry in registry.values():
|
|
323
|
+
if entry.get("kind") != kind:
|
|
324
|
+
continue
|
|
325
|
+
if str(entry.get("real_id") or "") != real_id:
|
|
326
|
+
continue
|
|
327
|
+
if kind == "playbook" and entry.get("source_kind") != source_kind:
|
|
328
|
+
continue
|
|
329
|
+
return entry
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
|
|
311
333
|
def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
312
334
|
"""Drain the buffered assistant turn to reflexio.
|
|
313
335
|
|
|
@@ -343,9 +365,8 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
|
343
365
|
prompt = payload.get("prompt") or (
|
|
344
366
|
_scan_transcript_for_user_text(entries) if runtime.is_codex() else ""
|
|
345
367
|
)
|
|
346
|
-
if runtime.is_codex():
|
|
347
|
-
|
|
348
|
-
return None
|
|
368
|
+
if runtime.is_codex() and internal_call.is_codex_internal_prompt(prompt):
|
|
369
|
+
return None
|
|
349
370
|
|
|
350
371
|
last_assistant_message = payload.get("last_assistant_message")
|
|
351
372
|
assistant_text = (
|
|
@@ -357,11 +378,16 @@ def handle(payload: dict[str, Any]) -> tuple[publish.PublishStatus, int] | None:
|
|
|
357
378
|
)
|
|
358
379
|
if (
|
|
359
380
|
runtime.is_codex()
|
|
360
|
-
and
|
|
381
|
+
and (
|
|
382
|
+
internal_call.is_codex_title_response(assistant_text)
|
|
383
|
+
or internal_call.is_codex_suggestions_response(assistant_text)
|
|
384
|
+
)
|
|
361
385
|
and not prompt
|
|
362
386
|
and not _has_unpublished_user_turn(session_id)
|
|
363
387
|
):
|
|
364
|
-
return
|
|
388
|
+
return None
|
|
389
|
+
if os.environ.get("CLAUDE_SMART_CITATIONS", "on") == "off":
|
|
390
|
+
assistant_text = cs_cite.strip_marker_lines(assistant_text)
|
|
365
391
|
text_cited_ids = cs_cite.parse_text_citations(assistant_text)
|
|
366
392
|
cited_items = _resolve_cited_items(session_id, text_cited_ids)
|
|
367
393
|
plan_decisions = _scan_transcript_for_plan_decisions(entries)
|
|
@@ -32,6 +32,8 @@ Detection signals, OR'd:
|
|
|
32
32
|
- Known Codex-internal prompt templates (title generation and home-screen
|
|
33
33
|
suggestions). These are model calls made by Codex itself, not user
|
|
34
34
|
coding turns, and must never be reflected into claude-smart memory.
|
|
35
|
+
- Known orphan Codex-internal response bodies, for cases where the Stop
|
|
36
|
+
payload contains only the generated metadata and not the internal prompt.
|
|
35
37
|
"""
|
|
36
38
|
|
|
37
39
|
from __future__ import annotations
|
|
@@ -140,3 +142,31 @@ def is_codex_title_response(content: Any) -> bool:
|
|
|
140
142
|
and isinstance(parsed.get("title"), str)
|
|
141
143
|
and bool(parsed["title"].strip())
|
|
142
144
|
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_codex_suggestions_response(content: Any) -> bool:
|
|
148
|
+
"""True for Codex's home-screen suggestion-generator response body.
|
|
149
|
+
|
|
150
|
+
Codex can run a separate suggestion task whose Stop payload contains
|
|
151
|
+
generated JSON like ``{"suggestions": [...]}`` with no corresponding user
|
|
152
|
+
turn. Those suggestions are UI metadata, not a user interaction.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(content, str):
|
|
155
|
+
return False
|
|
156
|
+
try:
|
|
157
|
+
parsed = json.loads(content)
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
return False
|
|
160
|
+
if not isinstance(parsed, dict) or set(parsed) != {"suggestions"}:
|
|
161
|
+
return False
|
|
162
|
+
suggestions = parsed.get("suggestions")
|
|
163
|
+
if not isinstance(suggestions, list):
|
|
164
|
+
return False
|
|
165
|
+
for item in suggestions:
|
|
166
|
+
if not isinstance(item, dict):
|
|
167
|
+
return False
|
|
168
|
+
if set(item) != {"title", "description", "prompt", "appId"}:
|
|
169
|
+
return False
|
|
170
|
+
if not all(isinstance(item.get(key), str) for key in item):
|
|
171
|
+
return False
|
|
172
|
+
return True
|
|
@@ -22,6 +22,7 @@ def publish_unpublished(
|
|
|
22
22
|
project_id: str,
|
|
23
23
|
force_extraction: bool,
|
|
24
24
|
skip_aggregation: bool,
|
|
25
|
+
override_learning_stall: bool = False,
|
|
25
26
|
adapter: Adapter | None = None,
|
|
26
27
|
) -> tuple[PublishStatus, int]:
|
|
27
28
|
"""Drain the session buffer to reflexio and stamp the high-water mark.
|
|
@@ -35,6 +36,9 @@ def publish_unpublished(
|
|
|
35
36
|
globally per agent rather than per project.
|
|
36
37
|
force_extraction (bool): Whether to ask reflexio to run extraction
|
|
37
38
|
synchronously instead of queuing for the next sweep.
|
|
39
|
+
override_learning_stall (bool): Whether to bypass a recorded
|
|
40
|
+
provider auth/billing stall. Automatic hooks must leave this
|
|
41
|
+
False; explicit manual retries set it True.
|
|
38
42
|
skip_aggregation (bool): When True, reflexio extracts preferences and
|
|
39
43
|
raw project-specific skill entries but skips the rollup into
|
|
40
44
|
shared skills. claude-smart passes False on every publish
|
|
@@ -63,6 +67,7 @@ def publish_unpublished(
|
|
|
63
67
|
project_id=project_id,
|
|
64
68
|
interactions=interactions,
|
|
65
69
|
force_extraction=force_extraction,
|
|
70
|
+
override_learning_stall=override_learning_stall,
|
|
66
71
|
skip_aggregation=skip_aggregation,
|
|
67
72
|
)
|
|
68
73
|
if ok:
|