agent-sin 0.1.0

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 (150) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +81 -0
  4. package/assets/logo.png +0 -0
  5. package/builtin-skills/_shared/_models_lib.py +227 -0
  6. package/builtin-skills/_shared/_profile_lib.py +98 -0
  7. package/builtin-skills/_shared/_schedules_lib.py +313 -0
  8. package/builtin-skills/_shared/_skill_settings_lib.py +153 -0
  9. package/builtin-skills/_shared/i18n.py +26 -0
  10. package/builtin-skills/memo-delete/main.py +155 -0
  11. package/builtin-skills/memo-delete/skill.yaml +57 -0
  12. package/builtin-skills/memo-index/main.py +178 -0
  13. package/builtin-skills/memo-index/skill.yaml +53 -0
  14. package/builtin-skills/memo-save/README.md +5 -0
  15. package/builtin-skills/memo-save/main.py +74 -0
  16. package/builtin-skills/memo-save/skill.yaml +52 -0
  17. package/builtin-skills/memo-search/README.md +10 -0
  18. package/builtin-skills/memo-search/main.py +97 -0
  19. package/builtin-skills/memo-search/skill.yaml +51 -0
  20. package/builtin-skills/memo-vector-search/main.py +121 -0
  21. package/builtin-skills/memo-vector-search/skill.yaml +53 -0
  22. package/builtin-skills/model-add/main.py +180 -0
  23. package/builtin-skills/model-add/skill.yaml +112 -0
  24. package/builtin-skills/model-list/main.py +93 -0
  25. package/builtin-skills/model-list/skill.yaml +48 -0
  26. package/builtin-skills/model-set/main.py +123 -0
  27. package/builtin-skills/model-set/skill.yaml +69 -0
  28. package/builtin-skills/profile-delete/_profile_lib.py +98 -0
  29. package/builtin-skills/profile-delete/main.py +98 -0
  30. package/builtin-skills/profile-delete/skill.yaml +64 -0
  31. package/builtin-skills/profile-edit/_profile_lib.py +98 -0
  32. package/builtin-skills/profile-edit/main.py +97 -0
  33. package/builtin-skills/profile-edit/skill.yaml +72 -0
  34. package/builtin-skills/profile-save/main.py +52 -0
  35. package/builtin-skills/profile-save/skill.yaml +69 -0
  36. package/builtin-skills/schedule-add/_schedules_lib.py +303 -0
  37. package/builtin-skills/schedule-add/main.py +137 -0
  38. package/builtin-skills/schedule-add/skill.yaml +94 -0
  39. package/builtin-skills/schedule-list/_schedules_lib.py +303 -0
  40. package/builtin-skills/schedule-list/main.py +86 -0
  41. package/builtin-skills/schedule-list/skill.yaml +45 -0
  42. package/builtin-skills/schedule-remove/_schedules_lib.py +303 -0
  43. package/builtin-skills/schedule-remove/main.py +69 -0
  44. package/builtin-skills/schedule-remove/skill.yaml +49 -0
  45. package/builtin-skills/schedule-toggle/_schedules_lib.py +303 -0
  46. package/builtin-skills/schedule-toggle/main.py +78 -0
  47. package/builtin-skills/schedule-toggle/skill.yaml +61 -0
  48. package/builtin-skills/skills-disable/main.py +63 -0
  49. package/builtin-skills/skills-disable/skill.yaml +52 -0
  50. package/builtin-skills/skills-enable/main.py +62 -0
  51. package/builtin-skills/skills-enable/skill.yaml +51 -0
  52. package/builtin-skills/todo-add/main.py +68 -0
  53. package/builtin-skills/todo-add/skill.yaml +53 -0
  54. package/builtin-skills/todo-delete/main.py +65 -0
  55. package/builtin-skills/todo-delete/skill.yaml +47 -0
  56. package/builtin-skills/todo-done/main.py +75 -0
  57. package/builtin-skills/todo-done/skill.yaml +47 -0
  58. package/builtin-skills/todo-list/main.py +91 -0
  59. package/builtin-skills/todo-list/skill.yaml +48 -0
  60. package/builtin-skills/todo-tick/main.py +125 -0
  61. package/builtin-skills/todo-tick/skill.yaml +48 -0
  62. package/dist/builder/build-action-classifier.d.ts +18 -0
  63. package/dist/builder/build-action-classifier.js +142 -0
  64. package/dist/builder/build-commands.d.ts +19 -0
  65. package/dist/builder/build-commands.js +133 -0
  66. package/dist/builder/build-flow.d.ts +72 -0
  67. package/dist/builder/build-flow.js +416 -0
  68. package/dist/builder/builder-session.d.ts +117 -0
  69. package/dist/builder/builder-session.js +1129 -0
  70. package/dist/builder/conversation-router.d.ts +22 -0
  71. package/dist/builder/conversation-router.js +69 -0
  72. package/dist/builder/intent-runtime-store.d.ts +7 -0
  73. package/dist/builder/intent-runtime-store.js +60 -0
  74. package/dist/builder/progress-format.d.ts +7 -0
  75. package/dist/builder/progress-format.js +46 -0
  76. package/dist/cli/index.d.ts +2 -0
  77. package/dist/cli/index.js +2835 -0
  78. package/dist/cli/spinner.d.ts +30 -0
  79. package/dist/cli/spinner.js +164 -0
  80. package/dist/core/ai-provider.d.ts +75 -0
  81. package/dist/core/ai-provider.js +678 -0
  82. package/dist/core/builtin-skills.d.ts +27 -0
  83. package/dist/core/builtin-skills.js +120 -0
  84. package/dist/core/chat-engine.d.ts +70 -0
  85. package/dist/core/chat-engine.js +812 -0
  86. package/dist/core/config.d.ts +127 -0
  87. package/dist/core/config.js +1379 -0
  88. package/dist/core/daily-memory-promotion.d.ts +21 -0
  89. package/dist/core/daily-memory-promotion.js +422 -0
  90. package/dist/core/i18n.d.ts +23 -0
  91. package/dist/core/i18n.js +167 -0
  92. package/dist/core/info-lines.d.ts +5 -0
  93. package/dist/core/info-lines.js +39 -0
  94. package/dist/core/input-schema.d.ts +2 -0
  95. package/dist/core/input-schema.js +156 -0
  96. package/dist/core/intent-router.d.ts +27 -0
  97. package/dist/core/intent-router.js +160 -0
  98. package/dist/core/logger.d.ts +60 -0
  99. package/dist/core/logger.js +240 -0
  100. package/dist/core/memory.d.ts +10 -0
  101. package/dist/core/memory.js +72 -0
  102. package/dist/core/message-utils.d.ts +13 -0
  103. package/dist/core/message-utils.js +104 -0
  104. package/dist/core/notifier.d.ts +17 -0
  105. package/dist/core/notifier.js +424 -0
  106. package/dist/core/output-writer.d.ts +13 -0
  107. package/dist/core/output-writer.js +100 -0
  108. package/dist/core/plan-decision.d.ts +16 -0
  109. package/dist/core/plan-decision.js +88 -0
  110. package/dist/core/profile-memory.d.ts +17 -0
  111. package/dist/core/profile-memory.js +142 -0
  112. package/dist/core/runtime.d.ts +50 -0
  113. package/dist/core/runtime.js +187 -0
  114. package/dist/core/scheduler.d.ts +28 -0
  115. package/dist/core/scheduler.js +155 -0
  116. package/dist/core/secrets.d.ts +31 -0
  117. package/dist/core/secrets.js +214 -0
  118. package/dist/core/service.d.ts +35 -0
  119. package/dist/core/service.js +479 -0
  120. package/dist/core/skill-planner.d.ts +24 -0
  121. package/dist/core/skill-planner.js +100 -0
  122. package/dist/core/skill-registry.d.ts +98 -0
  123. package/dist/core/skill-registry.js +319 -0
  124. package/dist/core/skill-scaffold.d.ts +33 -0
  125. package/dist/core/skill-scaffold.js +256 -0
  126. package/dist/core/skill-settings.d.ts +11 -0
  127. package/dist/core/skill-settings.js +63 -0
  128. package/dist/core/transfer.d.ts +31 -0
  129. package/dist/core/transfer.js +270 -0
  130. package/dist/core/update-notifier.d.ts +2 -0
  131. package/dist/core/update-notifier.js +140 -0
  132. package/dist/discord/bot.d.ts +96 -0
  133. package/dist/discord/bot.js +2424 -0
  134. package/dist/runtimes/codex-app-server.d.ts +53 -0
  135. package/dist/runtimes/codex-app-server.js +305 -0
  136. package/dist/runtimes/python-runner.d.ts +7 -0
  137. package/dist/runtimes/python-runner.js +302 -0
  138. package/dist/runtimes/typescript-runner.d.ts +5 -0
  139. package/dist/runtimes/typescript-runner.js +172 -0
  140. package/dist/skills-sdk/types.d.ts +38 -0
  141. package/dist/skills-sdk/types.js +1 -0
  142. package/dist/telegram/bot.d.ts +94 -0
  143. package/dist/telegram/bot.js +1219 -0
  144. package/install.ps1 +132 -0
  145. package/install.sh +130 -0
  146. package/package.json +60 -0
  147. package/templates/skill-python/main.py +74 -0
  148. package/templates/skill-python/skill.yaml +48 -0
  149. package/templates/skill-typescript/main.ts +87 -0
  150. package/templates/skill-typescript/skill.yaml +42 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ ## CHANGELOG
2
+
3
+ agent-sin のリリース履歴。形式は [Keep a Changelog](https://keepachangelog.com/ja/1.1.0/) ベース、バージョニングは [Semantic Versioning](https://semver.org/lang/ja/) に従う。
4
+
5
+ エントリ種別:
6
+
7
+ - **Added** — 新機能
8
+ - **Changed** — 既存挙動の変更(互換維持)
9
+ - **Deprecated** — 廃止予定(次メジャーで削除)
10
+ - **Removed** — 削除(破壊的変更)
11
+ - **Fixed** — バグ修正
12
+ - **Security** — セキュリティ修正
13
+
14
+ 互換性ポリシーは [公式ドキュメント](https://agent.shingoirie.com/versioning) を参照。
15
+
16
+ ---
17
+
18
+ ## [0.1.0] — 2026-05-14
19
+
20
+ ### Added
21
+
22
+ - 初回公開リリース。`agent-sin` CLI、スキル ランタイム(Python / TypeScript)、Discord bot、Builder モード、ビルトインスキル、Codex / OpenAI / Gemini / Claude Code プロバイダ統合、ローカルメモリ/インデックス/スケジューラの基盤。
23
+ - Telegram bot 連携(`src/telegram/`)。Discord と同じ chat / build / intent ルーティングを Telegram でも利用可能に。
24
+ - Discord 添付ファイル取り込み(テキスト/画像)と、ビルド進捗の detail モード。
25
+ - profile memory(`soul.md` / `user.md` / `memory.md`)と日次メモの自動昇格(`daily-memory-promotion`)。
26
+ - `profile-save` ビルトインスキル。
27
+ - `install.sh` によるセットアップ補助。
28
+ - 互換性ポリシー([versioning](https://agent.shingoirie.com/versioning))と CHANGELOG。
29
+
30
+ ### Changed
31
+
32
+ - ビルドモード進入時の固定文言「作業を開始しました」を廃止し、detail モード以外は進捗メッセージを送らない静かな挙動に変更。
33
+ - Discord/Telegram のビルド進捗は `AGENT_SIN_DISCORD_PROGRESS_DETAIL=1` / `AGENT_SIN_TELEGRAM_PROGRESS_DETAIL=1` または runtime 設定の `progress_detail=true` でのみ詳細表示。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agent-Sin contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ <p align="center">
2
+ <img src="assets/logo.png" alt="Agent-Sin logo" width="120">
3
+ </p>
4
+
5
+ <h1 align="center">Agent-Sin</h1>
6
+
7
+ <p align="center">
8
+ A safer, more reliable<br>
9
+ <strong>program-driven personal AI agent.</strong>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://agent.shingoirie.com">Website</a> ·
14
+ <a href="https://agent.shingoirie.com/overview">Docs</a> ·
15
+ <a href="https://agent.shingoirie.com/getting-started">Quick Start</a>
16
+ </p>
17
+
18
+ ---
19
+
20
+ Agent-Sin freezes daily work into small **Program Skills** instead of letting an LLM reinterpret every task. Conversation is handled by an LLM at the entrance; execution is delegated to verified programs.
21
+
22
+ > Design inspired by [openclaw/openclaw](https://github.com/openclaw/openclaw).
23
+
24
+ ## Why program-driven
25
+
26
+ | | Traditional AI agent | Agent-Sin |
27
+ |---|---|---|
28
+ | Execution | LLM follows steps every time | Runs pre-built programs |
29
+ | Speed | Bound to inference latency | Fast program execution |
30
+ | Stability | Output drifts run to run | Same input, same behavior |
31
+ | Cost | Charged per LLM call | No LLM cost for skill runs |
32
+ | Safety | Unexpected actions are possible | Only registered actions run |
33
+
34
+ When a new capability is needed, Build Mode uses Claude Code or Codex to generate a skill. After that it runs the same way — fast, cheap, and predictable.
35
+
36
+ ## Highlights
37
+
38
+ - **Conversation Mode and Build Mode** — daily chat stays minimum-privilege; elevated permissions kick in only while authoring or editing a skill.
39
+ - **Multi-channel** — talk to the same agent from terminal, Discord, or Telegram.
40
+ - **Flexible notifications** — OS, Discord, Telegram, Slack, and email.
41
+ - **Free model mix** — pick a light model for chat and a stronger one for skill authoring.
42
+ - **Always-on gateway** — starts at login and bundles the scheduler with the Discord / Telegram bots.
43
+ - **Long-term memory** — agent persona, your profile, and daily context persist across sessions.
44
+
45
+ ## Install
46
+
47
+ Requires Node.js 22+ and Python 3. Works on macOS, Linux, and Windows.
48
+
49
+ **macOS / Linux**
50
+
51
+ ```bash
52
+ curl -fsSL https://agent.shingoirie.com/install.sh | bash
53
+ ```
54
+
55
+ **Windows (PowerShell)**
56
+
57
+ ```powershell
58
+ irm https://agent.shingoirie.com/install.ps1 | iex
59
+ ```
60
+
61
+ The installer sets up `agent-sin`, runs initial setup, and registers a login service. Workspace data lives in `~/.agent-sin/`.
62
+
63
+ Full walkthrough: [Getting Started](https://agent.shingoirie.com/getting-started).
64
+
65
+ ## Documentation
66
+
67
+ - [Overview](https://agent.shingoirie.com/overview) — the big picture
68
+ - [Getting Started](https://agent.shingoirie.com/getting-started) — install to first conversation
69
+ - [Concepts](https://agent.shingoirie.com/concepts) — design and Runtime
70
+ - [Skill Authoring](https://agent.shingoirie.com/skill-authoring) — write your own skill
71
+ - [Built-in Skills](https://agent.shingoirie.com/built-in-skills) — bundled skills
72
+ - [CLI Reference](https://agent.shingoirie.com/cli) — every command
73
+ - [Configuration](https://agent.shingoirie.com/configuration) — settings and API keys
74
+ - [Gateway & Ops](https://agent.shingoirie.com/operations) — always-on, schedules, notifications, backup
75
+ - [Discord](https://agent.shingoirie.com/discord) · [Telegram](https://agent.shingoirie.com/telegram) — channel integrations
76
+ - [Versioning](https://agent.shingoirie.com/versioning) — compatibility and release policy
77
+ - [Changelog](CHANGELOG.md) — release history
78
+
79
+ ## License
80
+
81
+ MIT
Binary file
@@ -0,0 +1,227 @@
1
+ """Common helpers for model-add / model-set / model-list builtin skills.
2
+
3
+ Reads/writes ~/.agent-sin/models.yaml in the format used by
4
+ src/core/config.ts. PyYAML is used; we accept that block-level comments
5
+ written by hand may not be preserved on save. Round-trip safety is achieved
6
+ by re-parsing before writing.
7
+
8
+ Mirrors the PROVIDER_CATALOG in src/core/config.ts. Keep them in sync.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ try:
18
+ import yaml as _yaml # type: ignore
19
+ HAS_PYYAML = True
20
+ except Exception:
21
+ HAS_PYYAML = False
22
+
23
+
24
+ ALLOWED_EFFORTS = ("low", "medium", "high", "xhigh")
25
+ ALLOWED_ROLES = ("chat", "builder")
26
+
27
+
28
+ # Mirrors PROVIDER_CATALOG in src/core/config.ts.
29
+ PROVIDER_CATALOG: List[Dict[str, Any]] = [
30
+ {
31
+ "id": "codex",
32
+ "label": "Codex CLI",
33
+ "type": "cli",
34
+ "default_model": "gpt-5.5",
35
+ "needs_effort": True,
36
+ "default_chat_effort": "low",
37
+ "default_builder_effort": "xhigh",
38
+ },
39
+ {
40
+ "id": "claude-code",
41
+ "label": "Claude Code CLI",
42
+ "type": "cli",
43
+ "default_model": "opus",
44
+ "needs_effort": True,
45
+ "default_chat_effort": "medium",
46
+ "default_builder_effort": "xhigh",
47
+ },
48
+ {
49
+ "id": "openai",
50
+ "label": "OpenAI API",
51
+ "type": "api",
52
+ "default_model": "gpt-5.5",
53
+ "needs_effort": False,
54
+ },
55
+ {
56
+ "id": "gemini",
57
+ "label": "Google Gemini API",
58
+ "type": "api",
59
+ "default_model": "gemini-2.5-flash",
60
+ "needs_effort": False,
61
+ },
62
+ {
63
+ "id": "anthropic",
64
+ "label": "Anthropic API",
65
+ "type": "api",
66
+ "default_model": "claude-opus-4-7",
67
+ "needs_effort": False,
68
+ },
69
+ {
70
+ "id": "ollama",
71
+ "label": "Ollama (local)",
72
+ "type": "ollama",
73
+ "default_model": "gemma4:26b",
74
+ "needs_effort": False,
75
+ },
76
+ ]
77
+
78
+
79
+ PROVIDER_INDEX: Dict[str, Dict[str, Any]] = {p["id"]: p for p in PROVIDER_CATALOG}
80
+
81
+
82
+ def models_path(workspace: str) -> str:
83
+ return os.path.join(workspace, "models.yaml")
84
+
85
+
86
+ def load_models(workspace: str) -> Dict[str, Any]:
87
+ """Returns {"roles": {"chat": id?, "builder": id?}, "models": {id: entry, ...}}."""
88
+ path = models_path(workspace)
89
+ if not os.path.exists(path):
90
+ return {"roles": {}, "models": {}}
91
+ with open(path, "r", encoding="utf-8") as f:
92
+ raw = f.read()
93
+ if not raw.strip():
94
+ return {"roles": {}, "models": {}}
95
+ if not HAS_PYYAML:
96
+ raise RuntimeError("PyYAML is required to read models.yaml")
97
+ data = _yaml.safe_load(raw) or {}
98
+ if not isinstance(data, dict):
99
+ return {"roles": {}, "models": {}}
100
+ roles = data.get("roles") if isinstance(data.get("roles"), dict) else {}
101
+ models = data.get("models") if isinstance(data.get("models"), dict) else {}
102
+ # Normalize legacy "login" -> "cli" in memory.
103
+ normalized: Dict[str, Any] = {}
104
+ for entry_id, entry in models.items():
105
+ if not isinstance(entry, dict):
106
+ continue
107
+ copy = dict(entry)
108
+ if copy.get("type") == "login":
109
+ copy["type"] = "cli"
110
+ normalized[str(entry_id)] = copy
111
+ return {"roles": dict(roles), "models": normalized}
112
+
113
+
114
+ def save_models(workspace: str, data: Dict[str, Any]) -> str:
115
+ """Write models.yaml. May drop hand-written comments."""
116
+ if not HAS_PYYAML:
117
+ raise RuntimeError("PyYAML is required to write models.yaml")
118
+ path = models_path(workspace)
119
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
120
+ payload: Dict[str, Any] = {}
121
+ roles = data.get("roles") or {}
122
+ if isinstance(roles, dict) and roles:
123
+ payload["roles"] = {k: v for k, v in roles.items() if v}
124
+ payload["models"] = data.get("models") or {}
125
+ text = _yaml.safe_dump(
126
+ payload,
127
+ allow_unicode=True,
128
+ sort_keys=False,
129
+ default_flow_style=False,
130
+ )
131
+ # round-trip check before writing
132
+ parsed = _yaml.safe_load(text) or {}
133
+ if not isinstance(parsed.get("models"), dict) or len(parsed["models"]) != len(payload["models"]):
134
+ raise RuntimeError("Round-trip check failed when serializing models.yaml")
135
+ fd, tmp_path = tempfile.mkstemp(
136
+ prefix=".models.", suffix=".yaml.tmp", dir=os.path.dirname(path) or "."
137
+ )
138
+ try:
139
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
140
+ f.write(text)
141
+ os.replace(tmp_path, path)
142
+ except Exception:
143
+ try:
144
+ os.unlink(tmp_path)
145
+ except Exception:
146
+ pass
147
+ raise
148
+ return path
149
+
150
+
151
+ def derive_id(provider: str, effort: Optional[str], type_: str) -> str:
152
+ """Mirror src/core/config.ts deriveSetupId."""
153
+ if type_ == "cli" and effort:
154
+ return f"{provider}-{effort}"
155
+ if type_ == "ollama":
156
+ return "ollama" if provider == "ollama" else provider
157
+ return provider
158
+
159
+
160
+ def unique_id(base: str, existing: set) -> str:
161
+ if base not in existing:
162
+ return base
163
+ n = 2
164
+ while f"{base}-{n}" in existing:
165
+ n += 1
166
+ return f"{base}-{n}"
167
+
168
+
169
+ def build_entry(
170
+ catalog_entry: Dict[str, Any],
171
+ model: Optional[str],
172
+ effort: Optional[str],
173
+ ) -> Dict[str, Any]:
174
+ # フィールド順は既存 models.yaml に合わせる: type / provider / model / effort / enabled
175
+ entry: Dict[str, Any] = {"type": catalog_entry["type"]}
176
+ if catalog_entry["type"] != "ollama":
177
+ entry["provider"] = catalog_entry["id"]
178
+ if model:
179
+ entry["model"] = model
180
+ if effort:
181
+ entry["effort"] = effort
182
+ entry["enabled"] = True
183
+ return entry
184
+
185
+
186
+ def find_provider(provider: str) -> Optional[Dict[str, Any]]:
187
+ return PROVIDER_INDEX.get((provider or "").strip().lower())
188
+
189
+
190
+ def normalize_effort(raw: Optional[str]) -> Optional[str]:
191
+ if raw is None:
192
+ return None
193
+ value = str(raw).strip().lower()
194
+ if not value:
195
+ return None
196
+ if value not in ALLOWED_EFFORTS:
197
+ raise ValueError(
198
+ f'effort must be one of: {"/".join(ALLOWED_EFFORTS)} (got "{raw}")'
199
+ )
200
+ return value
201
+
202
+
203
+ def default_effort_for(catalog_entry: Dict[str, Any], role: str) -> Optional[str]:
204
+ if not catalog_entry.get("needs_effort"):
205
+ return None
206
+ if role == "chat":
207
+ return catalog_entry.get("default_chat_effort") or "low"
208
+ return catalog_entry.get("default_builder_effort") or "xhigh"
209
+
210
+
211
+ def entry_summary(entry_id: str, entry: Dict[str, Any]) -> str:
212
+ parts: List[str] = []
213
+ provider = entry.get("provider") or entry.get("type") or ""
214
+ parts.append(str(provider))
215
+ if entry.get("model"):
216
+ parts.append(str(entry["model"]))
217
+ if entry.get("effort"):
218
+ parts.append(f"effort={entry['effort']}")
219
+ return f"{entry_id} ({' / '.join(parts)})"
220
+
221
+
222
+ def entries_equivalent(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
223
+ keys = ("type", "provider", "model", "effort")
224
+ for k in keys:
225
+ if (a.get(k) or None) != (b.get(k) or None):
226
+ return False
227
+ return True
@@ -0,0 +1,98 @@
1
+ """Shared helpers for profile-edit / profile-delete skills.
2
+
3
+ soul.md / user.md / memory.md は profile-save が `\n## <timestamp>\n\n<text>\n`
4
+ 形式で追記する。これを「ヘッダー = '## ...' を境にしたエントリ列」として
5
+ 扱うためのパーサと再シリアライズを提供する。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import tempfile
13
+ from typing import List, Optional, Tuple
14
+
15
+
16
+ _HEADER_RE = re.compile(r"^##\s+(.+?)\s*$")
17
+
18
+
19
+ def profile_file(workspace: str, target: str) -> str:
20
+ return os.path.join(workspace, "memory", "profile", f"{target}.md")
21
+
22
+
23
+ def parse_profile(raw: str) -> Tuple[str, List[dict]]:
24
+ """Split into (preamble, entries[{ timestamp, text }]).
25
+
26
+ preamble は最初の '## ' より前の部分(ファイル冒頭のコメント等)。
27
+ 各 entry の `text` は本文のみ(前後の改行は trim 済み)。
28
+ """
29
+ lines = raw.splitlines(keepends=False)
30
+ pre: List[str] = []
31
+ entries: List[dict] = []
32
+ current: Optional[dict] = None
33
+ for line in lines:
34
+ m = _HEADER_RE.match(line)
35
+ if m:
36
+ if current is not None:
37
+ current["text"] = "\n".join(current["_body"]).strip()
38
+ del current["_body"]
39
+ entries.append(current)
40
+ current = {"timestamp": m.group(1).strip(), "_body": []}
41
+ continue
42
+ if current is None:
43
+ pre.append(line)
44
+ else:
45
+ current["_body"].append(line)
46
+ if current is not None:
47
+ current["text"] = "\n".join(current["_body"]).strip()
48
+ del current["_body"]
49
+ entries.append(current)
50
+ preamble = "\n".join(pre).rstrip("\n")
51
+ return preamble, entries
52
+
53
+
54
+ def serialize_profile(preamble: str, entries: List[dict]) -> str:
55
+ out: List[str] = []
56
+ if preamble:
57
+ out.append(preamble)
58
+ for entry in entries:
59
+ out.append("")
60
+ out.append(f"## {entry['timestamp']}")
61
+ out.append("")
62
+ out.append(entry["text"])
63
+ text = "\n".join(out)
64
+ if not text.endswith("\n"):
65
+ text += "\n"
66
+ return text
67
+
68
+
69
+ def write_atomic(path: str, content: str) -> None:
70
+ os.makedirs(os.path.dirname(path), exist_ok=True)
71
+ fd, tmp = tempfile.mkstemp(
72
+ prefix=".profile.", suffix=".md.tmp", dir=os.path.dirname(path),
73
+ )
74
+ try:
75
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
76
+ f.write(content)
77
+ os.replace(tmp, path)
78
+ except Exception:
79
+ try:
80
+ os.unlink(tmp)
81
+ except Exception:
82
+ pass
83
+ raise
84
+
85
+
86
+ def find_entry_index(entries: List[dict], *, index: Optional[int], timestamp: Optional[str]) -> int:
87
+ if timestamp:
88
+ for i, entry in enumerate(entries):
89
+ if entry["timestamp"] == timestamp:
90
+ return i
91
+ raise LookupError(f'timestamp "{timestamp}" にマッチするエントリがありません')
92
+ if index is not None:
93
+ if index < 1 or index > len(entries):
94
+ raise LookupError(
95
+ f"index {index} は範囲外です (entries={len(entries)})",
96
+ )
97
+ return index - 1
98
+ raise LookupError("index か timestamp のどちらかを指定してください")