claude-smart 0.2.31 → 0.2.33

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.
Files changed (60) hide show
  1. package/README.md +2 -2
  2. package/bin/claude-smart.js +222 -20
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/dashboard/app/api/config/route.ts +11 -2
  7. package/plugin/dashboard/app/api/health/route.ts +45 -6
  8. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +15 -7
  9. package/plugin/dashboard/app/api/rules/applied/route.ts +27 -0
  10. package/plugin/dashboard/app/configure/env/page.tsx +36 -31
  11. package/plugin/dashboard/app/configure/layout.tsx +1 -1
  12. package/plugin/dashboard/app/configure/server/page.tsx +8 -14
  13. package/plugin/dashboard/app/dashboard/page.tsx +311 -115
  14. package/plugin/dashboard/app/globals.css +80 -66
  15. package/plugin/dashboard/app/layout.tsx +13 -10
  16. package/plugin/dashboard/app/preferences/[id]/page.tsx +21 -32
  17. package/plugin/dashboard/app/preferences/page.tsx +154 -54
  18. package/plugin/dashboard/app/preferences/project/[id]/page.tsx +1 -0
  19. package/plugin/dashboard/app/rules/[id]/page.tsx +51 -0
  20. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +4 -4
  21. package/plugin/dashboard/app/sessions/page.tsx +14 -10
  22. package/plugin/dashboard/app/skills/page.tsx +175 -56
  23. package/plugin/dashboard/app/skills/project/[id]/page.tsx +22 -38
  24. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +20 -37
  25. package/plugin/dashboard/components/common/delete-learning-danger-zone.tsx +166 -0
  26. package/plugin/dashboard/components/common/empty-state.tsx +4 -2
  27. package/plugin/dashboard/components/common/learnings-badge.tsx +1 -1
  28. package/plugin/dashboard/components/common/page-header.tsx +5 -3
  29. package/plugin/dashboard/components/common/page-tabs.tsx +4 -4
  30. package/plugin/dashboard/components/common/stat-card.tsx +9 -5
  31. package/plugin/dashboard/components/layout/sidebar.tsx +24 -9
  32. package/plugin/dashboard/components/layout/top-bar.tsx +37 -25
  33. package/plugin/dashboard/components/ui/input.tsx +1 -0
  34. package/plugin/dashboard/hooks/use-settings.tsx +30 -61
  35. package/plugin/dashboard/hooks/use-stall-state.ts +5 -9
  36. package/plugin/dashboard/lib/config-file.ts +4 -0
  37. package/plugin/dashboard/lib/reflexio-client.ts +23 -48
  38. package/plugin/dashboard/lib/session-reader.ts +222 -6
  39. package/plugin/dashboard/lib/types.ts +21 -1
  40. package/plugin/dashboard/package-lock.json +70 -95
  41. package/plugin/dashboard/package.json +5 -2
  42. package/plugin/pyproject.toml +1 -1
  43. package/plugin/scripts/_lib.sh +61 -0
  44. package/plugin/scripts/backend-service.sh +13 -2
  45. package/plugin/scripts/cli.sh +1 -0
  46. package/plugin/scripts/codex-hook.js +101 -3
  47. package/plugin/scripts/dashboard-service.sh +95 -19
  48. package/plugin/scripts/hook_entry.sh +13 -7
  49. package/plugin/scripts/smart-install.sh +18 -13
  50. package/plugin/src/claude_smart/cli.py +138 -24
  51. package/plugin/src/claude_smart/context_format.py +244 -6
  52. package/plugin/src/claude_smart/context_inject.py +8 -1
  53. package/plugin/src/claude_smart/cs_cite.py +186 -34
  54. package/plugin/src/claude_smart/env_config.py +103 -0
  55. package/plugin/src/claude_smart/events/stop.py +32 -6
  56. package/plugin/src/claude_smart/ids.py +32 -0
  57. package/plugin/src/claude_smart/internal_call.py +30 -0
  58. package/plugin/src/claude_smart/publish.py +8 -3
  59. package/plugin/src/claude_smart/reflexio_adapter.py +106 -29
  60. 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
- 1 claude-smart learning applied [cs:s1-1a2b]
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
- - ``CITATION_INSTRUCTION``: the trailer text appended to injected context
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
- CITATION_INSTRUCTION = (
50
- "_First, fully answer the user citation does not change what or how "
51
- "you reply. Then, as a final step, consider whether to cite: if — and "
52
- "only if — an injected `[cs:…]` item materially changed your reply "
53
- "(different wording, action, or conclusion than you would have produced "
54
- "without it), append exactly one final citation line after your answer. "
55
- "Do not call a shell command or any other tool for citations. Ids come verbatim "
56
- "from the `[cs:…]` tags — keep the leading `p` (preference) or `s` "
57
- "(skill) and the `-<fp>` suffix. Use this exact format for one id: "
58
- "`✨ 1 claude-smart learning applied [cs:s1-ab12]`. Use this exact format "
59
- "for multiple ids: `✨ 2 claude-smart learnings applied [cs:s1-ab12,p2-cd34]`, "
60
- "where the number is the count of ids in the brackets. "
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
- The parser intentionally only accepts lines containing the visual
132
- ``claude-smart learning(s) applied`` marker, so ordinary references to
133
- injected ``[cs:...]`` ids inside an answer do not count as citations.
134
- When multiple matching lines exist, the last one wins because the
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
- matches = list(_TEXT_CITATION_LINE_RE.finditer(text or ""))
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
- return _parse_id_tokens(matches[-1].group("ids"))
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
- ids: list[str] = []
145
- for tok in _SPLIT_RE.split(raw_ids.strip()):
146
- if clean := _CLEAN_ID_RE.match(tok):
147
- ids.append(clean.group(1).lower())
148
- return ids
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,103 @@
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
+ REFLEXIO_USER_ID_ENV = "REFLEXIO_USER_ID"
13
+
14
+
15
+ def parse_env_line(line: str) -> tuple[str, str] | None:
16
+ """Parse a simple dotenv ``KEY=value`` line.
17
+
18
+ This intentionally ignores comments, blank lines, and shell features. The
19
+ Reflexio env writer emits plain quoted assignments, which is all
20
+ claude-smart needs here.
21
+ """
22
+ stripped = line.strip()
23
+ if not stripped or stripped.startswith("#"):
24
+ return None
25
+ if stripped.startswith("export "):
26
+ stripped = stripped[len("export ") :].lstrip()
27
+ key, sep, raw_value = stripped.partition("=")
28
+ if not sep:
29
+ return None
30
+ key = key.strip()
31
+ if not key or not key.replace("_", "").isalnum() or key[0].isdigit():
32
+ return None
33
+ value = raw_value.strip()
34
+ if len(value) >= 2 and (
35
+ (value[0] == value[-1] == '"') or (value[0] == value[-1] == "'")
36
+ ):
37
+ value = value[1:-1]
38
+ return key, value
39
+
40
+
41
+ def load_reflexio_env(path: Path | None = None) -> None:
42
+ """Load ``~/.reflexio/.env`` into ``os.environ`` without overriding values."""
43
+ path = path or REFLEXIO_ENV_PATH
44
+ try:
45
+ text = path.read_text()
46
+ except OSError:
47
+ return
48
+ for line in text.splitlines():
49
+ parsed = parse_env_line(line)
50
+ if parsed is None:
51
+ continue
52
+ key, value = parsed
53
+ os.environ.setdefault(key, value)
54
+
55
+
56
+ def set_env_vars(path: Path, values: dict[str, str]) -> list[str]:
57
+ """Upsert dotenv keys while preserving comments and unrelated entries."""
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ try:
60
+ existing = path.read_text()
61
+ except OSError:
62
+ existing = ""
63
+
64
+ lines = existing.splitlines()
65
+ seen: set[str] = set()
66
+ out: list[str] = []
67
+ for line in lines:
68
+ parsed = parse_env_line(line)
69
+ if parsed is None:
70
+ out.append(line)
71
+ continue
72
+ key, _old = parsed
73
+ if key in values:
74
+ out.append(f'{key}="{_escape_env_value(values[key])}"')
75
+ seen.add(key)
76
+ else:
77
+ out.append(line)
78
+
79
+ added: list[str] = []
80
+ for key, value in values.items():
81
+ if key in seen:
82
+ continue
83
+ out.append(f'{key}="{_escape_env_value(value)}"')
84
+ added.append(key)
85
+
86
+ content = "\n".join(out)
87
+ path.write_text(content + ("\n" if content else ""), encoding="utf-8")
88
+ path.chmod(0o600)
89
+ return added
90
+
91
+
92
+ def mask_secret(value: str) -> str:
93
+ """Return a display-safe API key preview."""
94
+ if not value:
95
+ return ""
96
+ if len(value) <= 8:
97
+ return "*" * len(value)
98
+ prefix = value[:5] if "-" in value[:8] else value[:4]
99
+ return f"{prefix}****{value[-4:]}"
100
+
101
+
102
+ def _escape_env_value(value: str) -> str:
103
+ 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.get(cid)
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
- if internal_call.is_codex_internal_prompt(prompt):
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 internal_call.is_codex_title_response(assistant_text)
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)
@@ -19,14 +19,22 @@ from __future__ import annotations
19
19
  import logging
20
20
  import os
21
21
  import subprocess # noqa: S404 — git invocation with a fixed flag set.
22
+ import uuid
22
23
  from pathlib import Path
23
24
 
25
+ from claude_smart import env_config
26
+
24
27
  _LOGGER = logging.getLogger(__name__)
25
28
 
26
29
 
27
30
  def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
28
31
  """Return a stable project identifier for the given working directory.
29
32
 
33
+ Managed Reflexio installs are user-scoped instead of project-scoped:
34
+ when ``REFLEXIO_API_KEY`` is configured, the persisted
35
+ ``REFLEXIO_USER_ID`` UUID is returned. Local installs without an API key
36
+ keep using the git/project name.
37
+
30
38
  Prefers the basename of the git toplevel (so worktrees, submodules, and
31
39
  `cd src/` all still map to the same project). Falls back to the cwd
32
40
  basename when the directory is not inside a git repo.
@@ -37,6 +45,10 @@ def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
37
45
  Returns:
38
46
  str: A non-empty identifier. Never raises.
39
47
  """
48
+ managed_user_id = _managed_user_id()
49
+ if managed_user_id:
50
+ return managed_user_id
51
+
40
52
  base = Path(cwd) if cwd is not None else Path.cwd()
41
53
  try:
42
54
  result = subprocess.run( # noqa: S603, S607 — fixed argv, cwd is a Path.
@@ -54,3 +66,23 @@ def resolve_project_id(cwd: str | os.PathLike[str] | None = None) -> str:
54
66
  except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
55
67
  _LOGGER.debug("git toplevel resolution failed: %s", exc)
56
68
  return base.name or "unknown-project"
69
+
70
+
71
+ def _managed_user_id() -> str:
72
+ env_config.load_reflexio_env()
73
+ if not os.environ.get(env_config.REFLEXIO_API_KEY_ENV):
74
+ return ""
75
+ user_id = os.environ.get(env_config.REFLEXIO_USER_ID_ENV, "").strip()
76
+ if user_id:
77
+ return user_id
78
+
79
+ user_id = str(uuid.uuid4())
80
+ try:
81
+ env_config.set_env_vars(
82
+ env_config.REFLEXIO_ENV_PATH,
83
+ {env_config.REFLEXIO_USER_ID_ENV: user_id},
84
+ )
85
+ except OSError as exc:
86
+ _LOGGER.debug("could not persist managed Reflexio user id: %s", exc)
87
+ os.environ[env_config.REFLEXIO_USER_ID_ENV] = user_id
88
+ return user_id
@@ -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,19 +22,23 @@ 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.
28
29
 
29
30
  Args:
30
31
  session_id (str): Claude Code session id, attached to each interaction.
31
- project_id (str): Stable project name; used as reflexio's
32
- ``user_id`` (preferences) so preferences accumulate at the project
33
- level across sessions. ``agent_version`` is hardcoded to
32
+ project_id (str): Stable user-scope id resolved by ``ids``. Local mode
33
+ uses the project name; managed/API-key mode uses the persisted
34
+ ``REFLEXIO_USER_ID`` UUID. ``agent_version`` is hardcoded to
34
35
  ``"claude-code"`` in the adapter so skills roll up
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: