agent-sin 0.1.12 → 0.1.16

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 (97) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +2 -1
  3. package/builtin-skills/_shared/_todo_lib.py +290 -0
  4. package/builtin-skills/even-g2-setup/main.ts +896 -0
  5. package/builtin-skills/even-g2-setup/skill.yaml +133 -0
  6. package/builtin-skills/memo-delete/main.py +28 -107
  7. package/builtin-skills/memo-delete/skill.yaml +10 -21
  8. package/builtin-skills/memo-index/main.py +96 -64
  9. package/builtin-skills/memo-index/skill.yaml +4 -10
  10. package/builtin-skills/memo-list/main.py +126 -72
  11. package/builtin-skills/memo-list/skill.yaml +8 -14
  12. package/builtin-skills/memo-save/main.py +191 -25
  13. package/builtin-skills/memo-save/skill.yaml +29 -5
  14. package/builtin-skills/memo-search/main.py +38 -18
  15. package/builtin-skills/memo-vector-search/main.py +11 -6
  16. package/builtin-skills/nightly-topic-knowledge/_feedback_lib.py +391 -0
  17. package/builtin-skills/nightly-topic-knowledge/_topics_lib.py +415 -0
  18. package/builtin-skills/nightly-topic-knowledge/main.py +403 -0
  19. package/builtin-skills/nightly-topic-knowledge/skill.yaml +88 -0
  20. package/builtin-skills/schedule-add/main.py +26 -0
  21. package/builtin-skills/service-restart/main.ts +249 -0
  22. package/builtin-skills/service-restart/skill.yaml +49 -0
  23. package/builtin-skills/todo-add/main.py +3 -1
  24. package/builtin-skills/todo-delete/main.py +3 -1
  25. package/builtin-skills/todo-done/main.py +3 -1
  26. package/builtin-skills/todo-list/main.py +4 -1
  27. package/builtin-skills/todo-tick/main.py +3 -1
  28. package/builtin-skills/topic-knowledge-read/main.py +118 -0
  29. package/builtin-skills/topic-knowledge-read/skill.yaml +49 -0
  30. package/dist/builder/build-action-classifier.d.ts +18 -0
  31. package/dist/builder/build-action-classifier.js +82 -1
  32. package/dist/builder/build-flow.d.ts +33 -4
  33. package/dist/builder/build-flow.js +251 -89
  34. package/dist/builder/builder-session.d.ts +1 -1
  35. package/dist/builder/builder-session.js +112 -7
  36. package/dist/builder/conversation-router.d.ts +4 -2
  37. package/dist/builder/conversation-router.js +19 -2
  38. package/dist/cli/index.js +323 -20
  39. package/dist/core/ai-provider.d.ts +1 -0
  40. package/dist/core/ai-provider.js +8 -3
  41. package/dist/core/chat-engine.d.ts +9 -3
  42. package/dist/core/chat-engine.js +1263 -146
  43. package/dist/core/config.d.ts +4 -0
  44. package/dist/core/config.js +82 -0
  45. package/dist/core/daily-memory-promotion.d.ts +7 -0
  46. package/dist/core/daily-memory-promotion.js +596 -18
  47. package/dist/core/image-attachments.d.ts +31 -0
  48. package/dist/core/image-attachments.js +237 -0
  49. package/dist/core/logger.d.ts +2 -1
  50. package/dist/core/logger.js +77 -1
  51. package/dist/core/memo-migration.d.ts +3 -0
  52. package/dist/core/memo-migration.js +422 -0
  53. package/dist/core/native-modules.d.ts +24 -0
  54. package/dist/core/native-modules.js +99 -0
  55. package/dist/core/notifier.d.ts +8 -3
  56. package/dist/core/notifier.js +191 -17
  57. package/dist/core/obsidian-vault.d.ts +19 -0
  58. package/dist/core/obsidian-vault.js +477 -0
  59. package/dist/core/operating-model.d.ts +2 -0
  60. package/dist/core/operating-model.js +15 -0
  61. package/dist/core/output-writer.d.ts +3 -2
  62. package/dist/core/output-writer.js +108 -7
  63. package/dist/core/profile-memory.js +22 -1
  64. package/dist/core/runtime.d.ts +2 -0
  65. package/dist/core/runtime.js +9 -1
  66. package/dist/core/secrets.d.ts +4 -0
  67. package/dist/core/secrets.js +34 -0
  68. package/dist/core/skill-history.d.ts +44 -0
  69. package/dist/core/skill-history.js +329 -0
  70. package/dist/core/skill-registry.d.ts +5 -0
  71. package/dist/core/skill-registry.js +11 -0
  72. package/dist/discord/bot.d.ts +1 -0
  73. package/dist/discord/bot.js +181 -10
  74. package/dist/even-g2/gateway.d.ts +15 -0
  75. package/dist/even-g2/gateway.js +868 -0
  76. package/dist/runtimes/codex-app-server.d.ts +5 -1
  77. package/dist/runtimes/codex-app-server.js +147 -8
  78. package/dist/runtimes/python-runner.js +82 -0
  79. package/dist/runtimes/typescript-runner.js +13 -1
  80. package/dist/skills-sdk/types.d.ts +19 -4
  81. package/dist/telegram/bot.d.ts +1 -0
  82. package/dist/telegram/bot.js +115 -7
  83. package/package.json +3 -1
  84. package/templates/even-g2-agent/README.md +83 -0
  85. package/templates/even-g2-agent/app.json +20 -0
  86. package/templates/even-g2-agent/index.html +31 -0
  87. package/templates/even-g2-agent/package-lock.json +1836 -0
  88. package/templates/even-g2-agent/package.json +22 -0
  89. package/templates/even-g2-agent/scripts/qr-auto.mjs +182 -0
  90. package/templates/even-g2-agent/src/embedded-config.ts +4 -0
  91. package/templates/even-g2-agent/src/main.ts +539 -0
  92. package/templates/even-g2-agent/src/style.css +70 -0
  93. package/templates/even-g2-agent/tsconfig.json +11 -0
  94. package/templates/skill-python/main.py +20 -2
  95. package/templates/skill-python/skill.yaml +9 -0
  96. package/templates/skill-typescript/main.ts +40 -5
  97. package/templates/skill-typescript/skill.yaml +9 -0
@@ -1,18 +1,19 @@
1
1
  """Builtin: memo-search
2
2
 
3
- input.sources.notes_dir 配下の *.md を全文走査し、query にすべての語が含まれる
4
- 最初の 1 行を結果に含める。outputs は持たず data.matches に結果を返すのみ。
3
+ notes/memo/*.md を全文走査し、query にすべての語が含まれる
4
+ 最初の本文行を結果に含める。outputs は持たず data.matches に結果を返すのみ。
5
5
 
6
6
  入力:
7
7
  args.query: 検索語 (空白区切りで AND 検索)
8
8
  args.limit: 上限 (1..50, default 10)
9
9
  出力:
10
- data.matches: [{file, line, text}]
10
+ data.matches: [{title, file, line, text}]
11
11
  """
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
15
  import os
16
+ import re
16
17
  import sys
17
18
  from pathlib import Path
18
19
 
@@ -20,6 +21,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(
20
21
  from i18n import localizer # noqa: E402
21
22
 
22
23
 
24
+ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
25
+
26
+
23
27
  async def run(ctx, input):
24
28
  loc = localizer(input)
25
29
  args = input.get("args", {})
@@ -34,46 +38,62 @@ async def run(ctx, input):
34
38
  ctx.log.error("memo-search: notes_dir not provided by runtime")
35
39
  return result("error", loc.t("Cannot search", "検索できません"), loc.t("The notes directory was not provided by the runtime.", "ノート保存先がRuntimeから渡されていません"), [])
36
40
 
37
- root = Path(notes_dir).expanduser().resolve()
38
- if not root.exists():
39
- ctx.log.info(f"memo-search: notes_dir does not exist yet: {root}")
41
+ memo_dir = Path(notes_dir).expanduser().resolve() / "memo"
42
+ if not memo_dir.exists():
43
+ ctx.log.info(f"memo-search: memo dir does not exist yet: {memo_dir}")
40
44
  return result("ok", loc.t("0 matches", "0件見つかりました"), loc.t("There are no memos yet.", "まだメモがありません"), [])
41
45
 
42
46
  terms = [term.casefold() for term in query.split() if term]
43
- ctx.log.info(f"memo-search: searching {len(terms)} term(s) in {root}, limit={limit}")
44
- matches = search_notes(root, terms, limit)
47
+ ctx.log.info(f"memo-search: searching {len(terms)} term(s) in {memo_dir}, limit={limit}")
48
+ matches = search_notes(memo_dir, Path(notes_dir).expanduser().resolve(), terms, limit)
45
49
  if not matches:
46
50
  return result("ok", loc.t("0 matches", "0件見つかりました"), loc.t(f'No memos contain "{query}".', f"「{query}」を含むメモは見つかりませんでした"), [])
47
51
 
48
52
  lines = [loc.t("Found memos:", "見つかったメモ:")]
49
53
  for item in matches:
50
- lines.append(f"- {item['file']}:{item['line']} {item['text']}")
54
+ lines.append(f"- {item['title']} ({item['file']}:{item['line']}) {item['text']}")
51
55
  return result("ok", loc.t(f"{len(matches)} matches", f"{len(matches)}件見つかりました"), "\n".join(lines), matches)
52
56
 
53
57
 
54
- def search_notes(root, terms, limit):
58
+ def search_notes(memo_dir, notes_root, terms, limit):
55
59
  matches = []
56
- for file in sorted(root.rglob("*.md")):
60
+ for file in sorted(memo_dir.glob("*.md")):
57
61
  if len(matches) >= limit:
58
62
  break
59
63
  try:
60
- resolved = file.resolve()
61
- if root != resolved and root not in resolved.parents:
62
- continue
63
- lines = file.read_text(encoding="utf-8", errors="ignore").splitlines()
64
+ raw = file.read_text(encoding="utf-8", errors="ignore")
64
65
  except OSError:
65
66
  continue
66
-
67
- for index, line in enumerate(lines, start=1):
67
+ body_start = 1
68
+ body = raw
69
+ m = FRONTMATTER_RE.match(raw)
70
+ if m:
71
+ body = raw[m.end():]
72
+ body_start = raw[: m.end()].count("\n") + 1
73
+ haystack = (file.stem + "\n" + body).casefold()
74
+ if not all(term in haystack for term in terms):
75
+ continue
76
+ for index, line in enumerate(body.splitlines(), start=body_start):
68
77
  if all(term in line.casefold() for term in terms):
69
78
  matches.append(
70
79
  {
71
- "file": str(file.relative_to(root)),
80
+ "title": file.stem,
81
+ "file": str(file.relative_to(notes_root)),
72
82
  "line": index,
73
83
  "text": snippet(line),
74
84
  }
75
85
  )
76
86
  break
87
+ else:
88
+ first_line = next((ln for ln in body.splitlines() if ln.strip()), "")
89
+ matches.append(
90
+ {
91
+ "title": file.stem,
92
+ "file": str(file.relative_to(notes_root)),
93
+ "line": body_start,
94
+ "text": snippet(first_line),
95
+ }
96
+ )
77
97
  return matches
78
98
 
79
99
 
@@ -9,7 +9,7 @@ memo-index で索引化した Chroma collection に対してクエリを embeddi
9
9
  args.collection: 索引コレクション名 (default: memo)
10
10
 
11
11
  出力:
12
- data.matches: [{text, file, line, timestamp, distance}]
12
+ data.matches: [{title, file, tags, updated, distance, snippet}]
13
13
  """
14
14
 
15
15
  from __future__ import annotations
@@ -86,13 +86,17 @@ async def run(ctx, input):
86
86
  dists = (r.get("distances") or [[]])[0]
87
87
  for doc, meta, dist in zip(docs, metas, dists):
88
88
  meta = meta or {}
89
+ snippet = (doc or "").strip().splitlines()[0] if doc else ""
90
+ if len(snippet) > 100:
91
+ snippet = snippet[:97] + "..."
89
92
  matches.append(
90
93
  {
91
- "text": doc,
94
+ "title": meta.get("title", ""),
92
95
  "file": meta.get("file", ""),
93
- "line": meta.get("line", 0),
94
- "timestamp": meta.get("timestamp", ""),
96
+ "tags": meta.get("tags", ""),
97
+ "updated": meta.get("updated", ""),
95
98
  "distance": float(dist),
99
+ "snippet": snippet,
96
100
  }
97
101
  )
98
102
 
@@ -103,8 +107,9 @@ async def run(ctx, input):
103
107
 
104
108
  lines = [loc.t("Closest memos:", "近いメモ:")]
105
109
  for i, m in enumerate(matches, start=1):
106
- lines.append(f" {i}) {m['file']}:{m['line']} dist={m['distance']:.3f}")
107
- lines.append(f" {m['text'][:80]}")
110
+ lines.append(f" {i}) {m['title']} dist={m['distance']:.3f}")
111
+ if m["snippet"]:
112
+ lines.append(f" {m['snippet']}")
108
113
  return result_value(
109
114
  "ok", loc.t(f"{len(matches)} matches", f"{len(matches)}件見つかりました"), "\n".join(lines), matches
110
115
  )
@@ -0,0 +1,391 @@
1
+ """Helpers for the skill-feedback AI step of nightly-topic-knowledge.
2
+
3
+ Reads the current skill catalog (id / name / description / phrases / enabled
4
+ state) from the builtin and user skills directories, builds an LLM prompt that
5
+ asks for actionable suggestions derived from the previous day's sources, and
6
+ parses the JSON response into a normalized list of proposals that can be
7
+ appended to ctx.history and broadcast over the configured notification
8
+ channel.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ from typing import Any, Iterable
17
+
18
+ try: # PyYAML is already used by other builtin skills.
19
+ import yaml as _yaml # type: ignore
20
+ HAS_PYYAML = True
21
+ except Exception:
22
+ HAS_PYYAML = False
23
+
24
+
25
+ PROPOSAL_TYPES = ("misalign", "repeat", "unused", "user-feedback")
26
+ TARGET_KINDS = ("existing", "new", "rule")
27
+ TARGET_CHANGES = (
28
+ "description",
29
+ "phrases",
30
+ "main",
31
+ "new",
32
+ "claude-md",
33
+ "other",
34
+ )
35
+
36
+ MAX_PROPOSALS = 12
37
+ MAX_TEXT_LEN = 600
38
+ MAX_TITLE_LEN = 120
39
+
40
+
41
+ # ---------- skill catalog ----------
42
+
43
+ def collect_skill_summary(skills_dir: str | None, builtin_skills_dir: str | None) -> list[dict[str, Any]]:
44
+ """Walk both skill roots and return a deduped list of skill summaries.
45
+
46
+ User skills shadow builtin ids when override=true; otherwise builtins win,
47
+ matching the runtime's registry behaviour. Keeps the prompt small by only
48
+ extracting fields the LLM actually needs.
49
+ """
50
+ by_id: dict[str, dict[str, Any]] = {}
51
+ for source, root in (("builtin", builtin_skills_dir), ("user", skills_dir)):
52
+ if not root or not os.path.isdir(root):
53
+ continue
54
+ for entry in sorted(os.listdir(root)):
55
+ if entry.startswith(".") or entry.startswith("_"):
56
+ continue
57
+ yaml_path = os.path.join(root, entry, "skill.yaml")
58
+ if not os.path.isfile(yaml_path):
59
+ continue
60
+ manifest = _read_yaml(yaml_path)
61
+ if not isinstance(manifest, dict):
62
+ continue
63
+ summary = _summarize_manifest(manifest, source)
64
+ if not summary:
65
+ continue
66
+ sid = summary["id"]
67
+ if source == "user" and sid in by_id and not bool(manifest.get("override")):
68
+ continue
69
+ by_id[sid] = summary
70
+ return sorted(by_id.values(), key=lambda s: s["id"])
71
+
72
+
73
+ def _read_yaml(path: str) -> Any:
74
+ try:
75
+ with open(path, "r", encoding="utf-8") as f:
76
+ text = f.read()
77
+ except Exception:
78
+ return None
79
+ if HAS_PYYAML:
80
+ try:
81
+ return _yaml.safe_load(text)
82
+ except Exception:
83
+ return None
84
+ return _fallback_skill_yaml(text)
85
+
86
+
87
+ def _fallback_skill_yaml(text: str) -> dict[str, Any]:
88
+ """Minimal scalar/list extractor used only when PyYAML is missing.
89
+
90
+ Skill.yaml uses nested structures for invocation/input/etc. that the
91
+ fallback can't fully parse — but the few top-level fields we care about
92
+ (id / name / description / enabled) are simple strings, so we can salvage
93
+ enough to keep the prompt useful instead of failing the whole step.
94
+ """
95
+ out: dict[str, Any] = {}
96
+ for line in text.splitlines():
97
+ match = re.match(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.*?)\s*$", line)
98
+ if not match:
99
+ continue
100
+ key, raw = match.group(1), match.group(2)
101
+ if not raw or raw.startswith("#"):
102
+ continue
103
+ if raw.startswith(('"', "'")) and raw.endswith(raw[0]):
104
+ raw = raw[1:-1]
105
+ if key in out:
106
+ continue
107
+ out[key] = raw
108
+ return out
109
+
110
+
111
+ def _summarize_manifest(manifest: dict[str, Any], source: str) -> dict[str, Any] | None:
112
+ sid = manifest.get("id")
113
+ if not isinstance(sid, str) or not sid:
114
+ return None
115
+ description = _pick_localized(manifest, "description")
116
+ invocation = manifest.get("invocation") or {}
117
+ phrases = _pick_localized_list(invocation, "phrases")
118
+ command = invocation.get("command") if isinstance(invocation, dict) else None
119
+ enabled = manifest.get("enabled")
120
+ return {
121
+ "id": sid,
122
+ "name": _pick_localized(manifest, "name") or sid,
123
+ "description": description or "",
124
+ "phrases": phrases,
125
+ "command": command if isinstance(command, str) else "",
126
+ "source": source,
127
+ "enabled": False if enabled is False else True,
128
+ }
129
+
130
+
131
+ def _pick_localized(manifest: dict[str, Any], key: str) -> str:
132
+ i18n = manifest.get(f"{key}_i18n")
133
+ if isinstance(i18n, dict):
134
+ ja = i18n.get("ja")
135
+ if isinstance(ja, str) and ja.strip():
136
+ return ja.strip()
137
+ en = i18n.get("en")
138
+ if isinstance(en, str) and en.strip():
139
+ return en.strip()
140
+ value = manifest.get(key)
141
+ if isinstance(value, str):
142
+ return value.strip()
143
+ return ""
144
+
145
+
146
+ def _pick_localized_list(node: Any, key: str) -> list[str]:
147
+ if not isinstance(node, dict):
148
+ return []
149
+ i18n = node.get(f"{key}_i18n")
150
+ if isinstance(i18n, dict):
151
+ ja = i18n.get("ja")
152
+ if isinstance(ja, list):
153
+ return [str(x).strip() for x in ja if isinstance(x, str) and x.strip()]
154
+ en = i18n.get("en")
155
+ if isinstance(en, list):
156
+ return [str(x).strip() for x in en if isinstance(x, str) and x.strip()]
157
+ raw = node.get(key)
158
+ if isinstance(raw, list):
159
+ return [str(x).strip() for x in raw if isinstance(x, str) and x.strip()]
160
+ return []
161
+
162
+
163
+ # ---------- prompt ----------
164
+
165
+ def build_feedback_prompt(
166
+ date_str: str,
167
+ source_bundle: str,
168
+ skill_catalog: Iterable[dict[str, Any]],
169
+ ) -> str:
170
+ catalog_lines: list[str] = []
171
+ for skill in skill_catalog:
172
+ state = "" if skill.get("enabled") else " [disabled]"
173
+ phrases = skill.get("phrases") or []
174
+ phrases_text = " / ".join(phrases[:6]) if phrases else ""
175
+ line = f"- {skill['id']}{state}: {skill.get('description', '').strip()}"
176
+ if phrases_text:
177
+ line += f" (phrases: {phrases_text})"
178
+ catalog_lines.append(line)
179
+ catalog_text = "\n".join(catalog_lines) if catalog_lines else "(no skills registered)"
180
+
181
+ schema = {
182
+ "proposals": [
183
+ {
184
+ "type": "<one of: misalign | repeat | unused | user-feedback>",
185
+ "title": "<short Japanese title under 60 chars>",
186
+ "citation": "<short quote / timestamp showing where in the sources this came from>",
187
+ "proposal": "<concrete change: what to add/remove/rename and where>",
188
+ "target_kind": "<existing | new | rule>",
189
+ "target_skill": "<existing skill id, or proposed new skill id (kebab-case), or empty for rule>",
190
+ "target_change": "<description | phrases | main | new | claude-md | other>",
191
+ }
192
+ ]
193
+ }
194
+
195
+ instructions = [
196
+ "You are reviewing one day of an individual user's conversations / daily memory / notes",
197
+ "to look for ways to improve the assistant's skill set. The goal is to surface concrete,",
198
+ "actionable proposals that the user can later batch-apply by replying with item numbers.",
199
+ "",
200
+ "Detect four categories — keep the `type` field to one of these literal strings:",
201
+ "- misalign: the assistant misread the user's intent, took the wrong tool, or had to redo work.",
202
+ "- repeat: the user asked for the same hand-driven task more than once → should become a skill.",
203
+ "- unused: an existing skill was not called when it should have been, or its phrases / description",
204
+ " did not match how the user actually asked for it.",
205
+ "- user-feedback: explicit corrective or affirming feedback the user gave that should change",
206
+ " default behaviour (rule / description / phrases).",
207
+ "",
208
+ "Hard rules:",
209
+ "- Output ONE JSON object only. No prose, no markdown fences.",
210
+ f"- At most {MAX_PROPOSALS} proposals. Skip trivial or speculative ones.",
211
+ "- Skip anything already obviously satisfied by the existing skills below.",
212
+ "- For `target_kind: existing`, `target_skill` MUST match an id from the skill catalog.",
213
+ "- For `target_kind: new`, propose a fresh kebab-case id that does NOT collide with any existing id.",
214
+ "- For `target_kind: rule`, leave `target_skill` empty and explain in `proposal` where the rule lives",
215
+ " (e.g. CLAUDE.md, feedback memory, runtime gate).",
216
+ "- `citation` must include a short verbatim quote or timestamp so the user can verify.",
217
+ "- All text in Japanese. Keep each field under 600 characters.",
218
+ "",
219
+ f"Date: {date_str}",
220
+ "",
221
+ "Current skill catalog (id [disabled?]: description (phrases: ...)):",
222
+ catalog_text,
223
+ "",
224
+ "JSON schema (output shape):",
225
+ json.dumps(schema, ensure_ascii=False, indent=2),
226
+ "",
227
+ "Sources:",
228
+ source_bundle or "(empty)",
229
+ ]
230
+ return "\n".join(instructions)
231
+
232
+
233
+ # ---------- response parsing ----------
234
+
235
+ _JSON_OBJECT_PATTERN = re.compile(r"\{[\s\S]*\}")
236
+
237
+
238
+ def parse_feedback_response(text: str) -> list[dict[str, Any]]:
239
+ raw = (text or "").strip()
240
+ if not raw:
241
+ return []
242
+ fenced = re.match(r"^```(?:json)?\s*([\s\S]*?)\s*```$", raw)
243
+ if fenced:
244
+ raw = fenced.group(1).strip()
245
+ parsed: Any
246
+ try:
247
+ parsed = json.loads(raw)
248
+ except Exception:
249
+ match = _JSON_OBJECT_PATTERN.search(raw)
250
+ if not match:
251
+ return []
252
+ try:
253
+ parsed = json.loads(match.group(0))
254
+ except Exception:
255
+ return []
256
+ if not isinstance(parsed, dict):
257
+ return []
258
+ items = parsed.get("proposals")
259
+ if not isinstance(items, list):
260
+ return []
261
+ out: list[dict[str, Any]] = []
262
+ known_ids: set[str] = set()
263
+ for raw_item in items:
264
+ normalized = _normalize_proposal(raw_item, known_ids)
265
+ if normalized is None:
266
+ continue
267
+ out.append(normalized)
268
+ if len(out) >= MAX_PROPOSALS:
269
+ break
270
+ return out
271
+
272
+
273
+ def _normalize_proposal(item: Any, known_ids: set[str]) -> dict[str, Any] | None:
274
+ if not isinstance(item, dict):
275
+ return None
276
+ ptype = _clamp_enum(item.get("type"), PROPOSAL_TYPES)
277
+ if not ptype:
278
+ return None
279
+ title = _clean_str(item.get("title"), MAX_TITLE_LEN)
280
+ if not title:
281
+ return None
282
+ citation = _clean_str(item.get("citation"), MAX_TEXT_LEN)
283
+ proposal = _clean_str(item.get("proposal"), MAX_TEXT_LEN)
284
+ if not proposal:
285
+ return None
286
+ target_kind = _clamp_enum(item.get("target_kind"), TARGET_KINDS) or "rule"
287
+ target_skill = _sanitize_skill_id(item.get("target_skill"))
288
+ if target_kind == "existing" and not target_skill:
289
+ return None
290
+ if target_kind == "new" and not target_skill:
291
+ return None
292
+ target_change = _clamp_enum(item.get("target_change"), TARGET_CHANGES) or "other"
293
+ # Avoid duplicate identical proposals from the model.
294
+ fingerprint = f"{ptype}|{title}|{target_kind}|{target_skill}"
295
+ if fingerprint in known_ids:
296
+ return None
297
+ known_ids.add(fingerprint)
298
+ return {
299
+ "type": ptype,
300
+ "title": title,
301
+ "citation": citation,
302
+ "proposal": proposal,
303
+ "target_kind": target_kind,
304
+ "target_skill": target_skill,
305
+ "target_change": target_change,
306
+ }
307
+
308
+
309
+ def _clean_str(value: Any, cap: int) -> str:
310
+ if not isinstance(value, str):
311
+ return ""
312
+ text = value.strip()
313
+ if len(text) > cap:
314
+ text = text[:cap].rstrip() + "…"
315
+ return text
316
+
317
+
318
+ def _clamp_enum(value: Any, allowed: Iterable[str]) -> str | None:
319
+ if not isinstance(value, str):
320
+ return None
321
+ text = value.strip().lower()
322
+ for candidate in allowed:
323
+ if text == candidate:
324
+ return candidate
325
+ return None
326
+
327
+
328
+ def _sanitize_skill_id(value: Any) -> str:
329
+ if not isinstance(value, str):
330
+ return ""
331
+ text = value.strip().lower()
332
+ text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
333
+ if not text:
334
+ return ""
335
+ if len(text) > 48:
336
+ text = text[:48].rstrip("-")
337
+ if not re.match(r"^[a-z][a-z0-9-]*$", text):
338
+ return ""
339
+ return text
340
+
341
+
342
+ # ---------- formatting for Telegram + history ----------
343
+
344
+ TYPE_LABEL = {
345
+ "misalign": "ズレ",
346
+ "repeat": "繰り返し",
347
+ "unused": "未活用",
348
+ "user-feedback": "フィードバック",
349
+ }
350
+
351
+ TARGET_KIND_LABEL = {
352
+ "existing": "既存",
353
+ "new": "新規",
354
+ "rule": "ルール",
355
+ }
356
+
357
+
358
+ def format_proposal_for_history(date_str: str, index: int, proposal: dict[str, Any]) -> str:
359
+ type_label = TYPE_LABEL.get(proposal["type"], proposal["type"])
360
+ kind_label = TARGET_KIND_LABEL.get(proposal["target_kind"], proposal["target_kind"])
361
+ target = proposal.get("target_skill") or ""
362
+ target_block = (
363
+ f"{kind_label}: {target}" if target else kind_label
364
+ )
365
+ parts = [
366
+ f"#{index}. [{type_label}] {proposal['title']}",
367
+ f"対象: {target_block} / {proposal.get('target_change') or 'other'}",
368
+ ]
369
+ citation = proposal.get("citation")
370
+ if citation:
371
+ parts.append(f"該当: {citation}")
372
+ parts.append(f"提案: {proposal['proposal']}")
373
+ return "\n".join(parts)
374
+
375
+
376
+ def format_notification(date_str: str, proposals: list[dict[str, Any]], char_budget: int = 3500) -> str:
377
+ if not proposals:
378
+ return ""
379
+ header = f"📋 スキル振り返り {date_str} ({len(proposals)}件)\n返信例: 「1, 3 を適用」「2 は不要」"
380
+ lines: list[str] = [header]
381
+ used = len(header)
382
+ for index, proposal in enumerate(proposals, start=1):
383
+ body = format_proposal_for_history(date_str, index, proposal)
384
+ addition = "\n\n" + body
385
+ if used + len(addition) > char_budget:
386
+ remaining = len(proposals) - (index - 1)
387
+ lines.append(f"\n\n…他 {remaining} 件は保存済み(履歴参照)")
388
+ break
389
+ lines.append(addition)
390
+ used += len(addition)
391
+ return "".join(lines)