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
@@ -0,0 +1,313 @@
1
+ """Common helpers for schedule-* builtin skills.
2
+
3
+ Provides load / dump / cron-validation helpers for the
4
+ ~/.agent-sin/schedules.yaml file. PyYAML is used if available;
5
+ otherwise a minimal hand-written parser/serializer (sufficient for the
6
+ restricted schedules schema) is used.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import tempfile
13
+ from typing import Any, Dict, List
14
+
15
+ try:
16
+ import yaml as _yaml # type: ignore
17
+ HAS_PYYAML = True
18
+ except Exception:
19
+ HAS_PYYAML = False
20
+
21
+
22
+ CANONICAL_KEY_ORDER = [
23
+ "id",
24
+ "description",
25
+ "cron",
26
+ "skill",
27
+ "args",
28
+ "enabled",
29
+ "approve",
30
+ "timezone",
31
+ ]
32
+
33
+
34
+ def schedules_path(workspace: str) -> str:
35
+ return os.path.join(workspace, "schedules.yaml")
36
+
37
+
38
+ def legacy_schedules_path(workspace: str) -> str:
39
+ return os.path.join(workspace, "schedules", "schedules.yaml")
40
+
41
+
42
+ def _migrate_legacy_schedules(workspace: str) -> None:
43
+ target = schedules_path(workspace)
44
+ if os.path.exists(target):
45
+ return
46
+ legacy = legacy_schedules_path(workspace)
47
+ if not os.path.exists(legacy):
48
+ return
49
+ with open(legacy, "r", encoding="utf-8") as src:
50
+ content = src.read()
51
+ with open(target, "w", encoding="utf-8") as dst:
52
+ dst.write(content)
53
+ try:
54
+ os.remove(legacy)
55
+ os.rmdir(os.path.dirname(legacy))
56
+ except OSError:
57
+ pass
58
+
59
+
60
+ def load_schedules(workspace: str) -> List[Dict[str, Any]]:
61
+ path = schedules_path(workspace)
62
+ if not os.path.exists(path):
63
+ legacy = legacy_schedules_path(workspace)
64
+ if os.path.exists(legacy):
65
+ path = legacy
66
+ else:
67
+ return []
68
+ with open(path, "r", encoding="utf-8") as f:
69
+ raw = f.read()
70
+ return _parse_schedules(raw)
71
+
72
+
73
+ def _parse_schedules(raw: str) -> List[Dict[str, Any]]:
74
+ if not raw.strip():
75
+ return []
76
+ if HAS_PYYAML:
77
+ data = _yaml.safe_load(raw) or {}
78
+ else:
79
+ data = _minimal_parse(raw)
80
+ items = data.get("schedules") if isinstance(data, dict) else None
81
+ return list(items) if isinstance(items, list) else []
82
+
83
+
84
+ def _minimal_parse(raw: str) -> Dict[str, Any]:
85
+ schedules: List[Dict[str, Any]] = []
86
+ current: Dict[str, Any] | None = None
87
+ in_args = False
88
+ in_schedules = False
89
+ for line in raw.splitlines():
90
+ if not line.strip() or line.lstrip().startswith("#"):
91
+ continue
92
+ if line.startswith("schedules:"):
93
+ in_schedules = True
94
+ rest = line[len("schedules:"):].strip()
95
+ if rest == "[]":
96
+ return {"schedules": []}
97
+ continue
98
+ if not in_schedules:
99
+ continue
100
+ indent = len(line) - len(line.lstrip(" "))
101
+ body = line.strip()
102
+ if body.startswith("- "):
103
+ if current is not None:
104
+ schedules.append(current)
105
+ current = {}
106
+ in_args = False
107
+ body = body[2:].strip()
108
+ if ":" in body:
109
+ k, _, v = body.partition(":")
110
+ current[k.strip()] = _parse_scalar(v.strip())
111
+ continue
112
+ if current is None:
113
+ continue
114
+ if in_args and indent >= 6:
115
+ if ":" in body:
116
+ k, _, v = body.partition(":")
117
+ current.setdefault("args", {})
118
+ current["args"][k.strip()] = _parse_scalar(v.strip())
119
+ continue
120
+ if ":" in body:
121
+ k, _, v = body.partition(":")
122
+ key = k.strip()
123
+ value = v.strip()
124
+ if key == "args" and value in ("", "{}"):
125
+ in_args = value == ""
126
+ current["args"] = {}
127
+ else:
128
+ in_args = False
129
+ current[key] = _parse_scalar(value)
130
+ if current is not None:
131
+ schedules.append(current)
132
+ return {"schedules": schedules}
133
+
134
+
135
+ def _parse_scalar(value: str) -> Any:
136
+ if value == "":
137
+ return ""
138
+ if value.startswith('"') and value.endswith('"'):
139
+ try:
140
+ return bytes(value[1:-1], "utf-8").decode("unicode_escape")
141
+ except Exception:
142
+ return value[1:-1]
143
+ if value.startswith("'") and value.endswith("'"):
144
+ return value[1:-1]
145
+ low = value.lower()
146
+ if low == "true":
147
+ return True
148
+ if low == "false":
149
+ return False
150
+ if low in ("null", "~"):
151
+ return None
152
+ try:
153
+ if "." in value:
154
+ return float(value)
155
+ return int(value)
156
+ except ValueError:
157
+ return value
158
+
159
+
160
+ def dump_schedules(items: List[Dict[str, Any]]) -> str:
161
+ if not items:
162
+ return "schedules: []\n"
163
+ if HAS_PYYAML:
164
+ cleaned = [_clean_item(item) for item in items]
165
+ return _yaml.safe_dump(
166
+ {"schedules": cleaned},
167
+ allow_unicode=True,
168
+ sort_keys=False,
169
+ default_flow_style=False,
170
+ )
171
+ lines = ["schedules:"]
172
+ for item in items:
173
+ cleaned = _clean_item(item)
174
+ keys = [k for k in CANONICAL_KEY_ORDER if k in cleaned]
175
+ keys.extend(k for k in cleaned.keys() if k not in keys)
176
+ first = True
177
+ for k in keys:
178
+ v = cleaned[k]
179
+ prefix = " - " if first else " "
180
+ first = False
181
+ if k == "args":
182
+ if not v:
183
+ lines.append(f"{prefix}args: {{}}")
184
+ continue
185
+ lines.append(f"{prefix}args:")
186
+ for ak, av in v.items():
187
+ lines.append(f" {ak}: {_yaml_scalar(av)}")
188
+ else:
189
+ lines.append(f"{prefix}{k}: {_yaml_scalar(v)}")
190
+ lines.append("")
191
+ return "\n".join(lines)
192
+
193
+
194
+ def _clean_item(item: Dict[str, Any]) -> Dict[str, Any]:
195
+ return {k: v for k, v in item.items() if v is not None}
196
+
197
+
198
+ def _yaml_scalar(v: Any) -> str:
199
+ if v is None:
200
+ return "null"
201
+ if isinstance(v, bool):
202
+ return "true" if v else "false"
203
+ if isinstance(v, (int, float)):
204
+ return str(v)
205
+ s = str(v)
206
+ if _needs_quote(s):
207
+ escaped = s.replace("\\", "\\\\").replace("\"", "\\\"")
208
+ return f'"{escaped}"'
209
+ return s
210
+
211
+
212
+ def _needs_quote(s: str) -> bool:
213
+ if s == "":
214
+ return True
215
+ if s.strip() != s:
216
+ return True
217
+ if s.lower() in ("null", "true", "false", "yes", "no", "on", "off", "~"):
218
+ return True
219
+ bad_start = set("-?:[]{},#&*!|>'\"%@`")
220
+ if s[0] in bad_start:
221
+ return True
222
+ for ch in s:
223
+ if ch in (":", "#", "\n"):
224
+ return True
225
+ return False
226
+
227
+
228
+ def write_schedules_atomic(workspace: str, items: List[Dict[str, Any]]) -> str:
229
+ _migrate_legacy_schedules(workspace)
230
+ path = schedules_path(workspace)
231
+ os.makedirs(os.path.dirname(path), exist_ok=True)
232
+ text = dump_schedules(items)
233
+ # round-trip check before writing
234
+ roundtrip = _parse_schedules(text)
235
+ if len(roundtrip) != len(items):
236
+ raise RuntimeError(
237
+ "Round-trip check failed: parsed entry count "
238
+ f"{len(roundtrip)} != original {len(items)}",
239
+ )
240
+ fd, tmp_path = tempfile.mkstemp(
241
+ prefix=".schedules.", suffix=".yaml.tmp", dir=os.path.dirname(path)
242
+ )
243
+ try:
244
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
245
+ f.write(text)
246
+ os.replace(tmp_path, path)
247
+ except Exception:
248
+ try:
249
+ os.unlink(tmp_path)
250
+ except Exception:
251
+ pass
252
+ raise
253
+ return path
254
+
255
+
256
+ def validate_cron(raw: str) -> None:
257
+ fields = raw.strip().split()
258
+ if len(fields) != 5:
259
+ raise ValueError(
260
+ f'Cron must have 5 fields ("min hour dom month dow"): "{raw}"',
261
+ )
262
+ spec = [
263
+ (0, 59, "minute"),
264
+ (0, 23, "hour"),
265
+ (1, 31, "day-of-month"),
266
+ (1, 12, "month"),
267
+ (0, 6, "day-of-week"),
268
+ ]
269
+ for field, (mn, mx, label) in zip(fields, spec):
270
+ _validate_cron_field(field, mn, mx, label)
271
+
272
+
273
+ def _validate_cron_field(field: str, mn: int, mx: int, label: str) -> None:
274
+ for part in field.split(","):
275
+ seg = part.strip()
276
+ if not seg:
277
+ raise ValueError(f'Empty segment in {label} field: "{field}"')
278
+ body = seg
279
+ step = 1
280
+ if "/" in body:
281
+ body, _, step_raw = body.partition("/")
282
+ try:
283
+ step = int(step_raw)
284
+ except ValueError as exc:
285
+ raise ValueError(
286
+ f'Invalid step "{step_raw}" in {label} field: "{field}"',
287
+ ) from exc
288
+ if step <= 0:
289
+ raise ValueError(
290
+ f'Invalid step "{step_raw}" in {label} field: "{field}"',
291
+ )
292
+ if body == "" or body == "*":
293
+ continue
294
+ if "-" in body:
295
+ a, _, b = body.partition("-")
296
+ try:
297
+ fr = int(a)
298
+ to = int(b)
299
+ except ValueError as exc:
300
+ raise ValueError(
301
+ f'Invalid value "{body}" in {label} field: "{field}"',
302
+ ) from exc
303
+ else:
304
+ try:
305
+ fr = to = int(body)
306
+ except ValueError as exc:
307
+ raise ValueError(
308
+ f'Invalid value "{body}" in {label} field: "{field}"',
309
+ ) from exc
310
+ if fr < mn or to > mx or fr > to:
311
+ raise ValueError(
312
+ f'{label} value out of range ({fr}-{to}); allowed {mn}-{mx} for "{field}"',
313
+ )
@@ -0,0 +1,153 @@
1
+ """Common helpers for skills-disable / skills-enable builtin skills.
2
+
3
+ Reads/writes ~/.agent-sin/skill-settings.yaml in the same format used by
4
+ src/core/skill-settings.ts. PyYAML is used if available; otherwise a minimal
5
+ serializer for the single-key `disabled: [...]` document is used.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import tempfile
12
+ from typing import Any, Dict, List, Tuple
13
+
14
+ try:
15
+ import yaml as _yaml # type: ignore
16
+ HAS_PYYAML = True
17
+ except Exception:
18
+ HAS_PYYAML = False
19
+
20
+
21
+ def skill_settings_path(workspace: str) -> str:
22
+ return os.path.join(workspace, "skill-settings.yaml")
23
+
24
+
25
+ def load_skill_settings(workspace: str) -> Dict[str, Any]:
26
+ path = skill_settings_path(workspace)
27
+ if not os.path.exists(path):
28
+ return {"disabled": []}
29
+ with open(path, "r", encoding="utf-8") as f:
30
+ raw = f.read()
31
+ if not raw.strip():
32
+ return {"disabled": []}
33
+ if HAS_PYYAML:
34
+ data = _yaml.safe_load(raw) or {}
35
+ else:
36
+ data = _minimal_parse(raw)
37
+ disabled = data.get("disabled") if isinstance(data, dict) else None
38
+ items: List[str] = []
39
+ if isinstance(disabled, list):
40
+ for entry in disabled:
41
+ if isinstance(entry, str) and entry.strip():
42
+ items.append(entry.strip())
43
+ return {"disabled": items}
44
+
45
+
46
+ def dump_skill_settings(settings: Dict[str, Any]) -> str:
47
+ disabled = sorted(set(settings.get("disabled") or []))
48
+ if not disabled:
49
+ return "disabled: []\n"
50
+ if HAS_PYYAML:
51
+ return _yaml.safe_dump(
52
+ {"disabled": disabled},
53
+ allow_unicode=True,
54
+ sort_keys=False,
55
+ default_flow_style=False,
56
+ )
57
+ lines = ["disabled:"]
58
+ for entry in disabled:
59
+ lines.append(f" - {_yaml_scalar(entry)}")
60
+ lines.append("")
61
+ return "\n".join(lines)
62
+
63
+
64
+ def save_skill_settings(workspace: str, settings: Dict[str, Any]) -> str:
65
+ path = skill_settings_path(workspace)
66
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
67
+ text = dump_skill_settings(settings)
68
+ fd, tmp_path = tempfile.mkstemp(
69
+ prefix=".skill-settings.", suffix=".yaml.tmp", dir=os.path.dirname(path) or "."
70
+ )
71
+ try:
72
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
73
+ f.write(text)
74
+ os.replace(tmp_path, path)
75
+ except Exception:
76
+ try:
77
+ os.unlink(tmp_path)
78
+ except Exception:
79
+ pass
80
+ raise
81
+ return path
82
+
83
+
84
+ def set_skill_enabled(
85
+ workspace: str, skill_id: str, enabled: bool
86
+ ) -> Tuple[bool, Dict[str, Any]]:
87
+ settings = load_skill_settings(workspace)
88
+ disabled: List[str] = list(settings.get("disabled") or [])
89
+ was_disabled = skill_id in disabled
90
+ if enabled:
91
+ if not was_disabled:
92
+ return False, settings
93
+ disabled = [d for d in disabled if d != skill_id]
94
+ else:
95
+ if was_disabled:
96
+ return False, settings
97
+ disabled.append(skill_id)
98
+ settings["disabled"] = disabled
99
+ save_skill_settings(workspace, settings)
100
+ return True, settings
101
+
102
+
103
+ def _minimal_parse(raw: str) -> Dict[str, Any]:
104
+ disabled: List[str] = []
105
+ in_disabled = False
106
+ for line in raw.splitlines():
107
+ stripped = line.strip()
108
+ if not stripped or stripped.startswith("#"):
109
+ continue
110
+ if line.startswith("disabled:"):
111
+ rest = line[len("disabled:"):].strip()
112
+ if rest == "[]" or rest == "":
113
+ in_disabled = rest == ""
114
+ continue
115
+ if rest.startswith("[") and rest.endswith("]"):
116
+ body = rest[1:-1]
117
+ for token in body.split(","):
118
+ item = token.strip().strip('"').strip("'")
119
+ if item:
120
+ disabled.append(item)
121
+ in_disabled = False
122
+ continue
123
+ if in_disabled:
124
+ if stripped.startswith("- "):
125
+ value = stripped[2:].strip().strip('"').strip("'")
126
+ if value:
127
+ disabled.append(value)
128
+ else:
129
+ in_disabled = False
130
+ return {"disabled": disabled}
131
+
132
+
133
+ def _yaml_scalar(value: str) -> str:
134
+ if _needs_quote(value):
135
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
136
+ return f'"{escaped}"'
137
+ return value
138
+
139
+
140
+ def _needs_quote(value: str) -> bool:
141
+ if value == "":
142
+ return True
143
+ if value.strip() != value:
144
+ return True
145
+ if value.lower() in ("null", "true", "false", "yes", "no", "on", "off", "~"):
146
+ return True
147
+ bad_start = set("-?:[]{},#&*!|>'\"%@`")
148
+ if value[0] in bad_start:
149
+ return True
150
+ for ch in value:
151
+ if ch in (":", "#", "\n"):
152
+ return True
153
+ return False
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ class Localizer:
7
+ def __init__(self, locale: str):
8
+ self.locale = locale
9
+
10
+ def t(self, en: str, ja: str) -> str:
11
+ return ja if self.locale == "ja" else en
12
+
13
+
14
+ def localizer(input_payload: dict | None = None) -> Localizer:
15
+ return Localizer(detect_locale(input_payload))
16
+
17
+
18
+ def detect_locale(input_payload: dict | None = None) -> str:
19
+ sources = (input_payload or {}).get("sources") or {}
20
+ explicit = str(sources.get("locale") or os.environ.get("AGENT_SIN_LOCALE") or "").strip().lower()
21
+ if explicit in {"en", "ja"}:
22
+ return explicit
23
+ lang = str(os.environ.get("LC_ALL") or os.environ.get("LANG") or "").strip().lower()
24
+ if lang:
25
+ return "ja" if lang.startswith("ja") else "en"
26
+ return "en"
@@ -0,0 +1,155 @@
1
+ """Builtin: memo-delete
2
+
3
+ memo-save が書き込むデイリーノートからメモを1件削除する。
4
+ バレット行 + 続く 2 スペースインデントの継続行をまとめて取り除く。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import sys
12
+ import tempfile
13
+ from datetime import datetime
14
+
15
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_shared"))
16
+ from i18n import localizer # noqa: E402
17
+
18
+
19
+ _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
20
+ _MEMO_LINE_RE = re.compile(r"^-\s+")
21
+ _CONTINUATION_RE = re.compile(r"^ \S")
22
+
23
+
24
+ async def run(ctx, input):
25
+ loc = localizer(input)
26
+ args = input.get("args", {}) or {}
27
+ workspace = input.get("sources", {}).get("workspace", "")
28
+ notes_dir = input.get("sources", {}).get("notes_dir", "")
29
+ if not workspace or not notes_dir:
30
+ return _err(loc.t("Workspace unavailable", "ワークスペース不明"), loc.t("workspace / notes_dir is unavailable.", "workspace / notes_dir が取得できません"))
31
+
32
+ date_str = str(args.get("date", "")).strip()
33
+ if not date_str:
34
+ date_str = datetime.now().strftime("%Y-%m-%d")
35
+ if not _DATE_RE.match(date_str):
36
+ return _err(loc.t("Invalid date", "日付不正"), loc.t("Use YYYY-MM-DD for date.", "date は YYYY-MM-DD 形式で指定してください"))
37
+
38
+ match = args.get("match")
39
+ match = str(match).strip() if match else None
40
+ index = args.get("index")
41
+ if index is not None and not isinstance(index, int):
42
+ return _err(loc.t("Invalid index", "index不正"), loc.t("index must be a positive integer.", "index は正の整数で指定してください"))
43
+ if not match and index is None:
44
+ return _err(loc.t("Cannot identify memo", "特定不可"), loc.t("Specify either match or index.", "match か index のどちらかを指定してください"))
45
+
46
+ year, month, _ = date_str.split("-")
47
+ path = os.path.join(notes_dir, year, month, f"{date_str}.md")
48
+ if not os.path.exists(path):
49
+ return _err(loc.t("File not found", "ファイルなし"), loc.t(f"No memo file exists for {date_str}.", f"{date_str} のメモファイルがありません"))
50
+
51
+ with open(path, "r", encoding="utf-8") as f:
52
+ raw = f.read()
53
+ lines = raw.splitlines(keepends=False)
54
+
55
+ memo_ranges = _collect_memo_ranges(lines)
56
+ if not memo_ranges:
57
+ return _err(loc.t("No memos", "メモなし"), loc.t(f"No memo lines can be removed for {date_str}.", f"{date_str} に削除対象のメモ行がありません"))
58
+
59
+ target_range = None
60
+ if index is not None:
61
+ if index < 1 or index > len(memo_ranges):
62
+ return _err(loc.t("Invalid index", "index不正"), loc.t(f"index {index} is out of range ({len(memo_ranges)} memos).", f"index {index} は範囲外です (メモ {len(memo_ranges)} 件)"))
63
+ target_range = memo_ranges[index - 1]
64
+ else:
65
+ candidates = [
66
+ r for r in memo_ranges
67
+ if any(match in lines[i] for i in range(r[0], r[1]))
68
+ ]
69
+ if not candidates:
70
+ return _err(loc.t("Not found", "見つかりません"), loc.t(f'No memo matches "{match}".', f'"{match}" に一致するメモがありません'))
71
+ if len(candidates) > 1:
72
+ preview = "\n".join(
73
+ f" {idx + 1}. {lines[start]}" for idx, (start, _end) in enumerate(candidates[:5])
74
+ )
75
+ return _err(
76
+ loc.t("Ambiguous match", "曖昧です"),
77
+ loc.t(f"{len(candidates)} memos matched. Specify index:\n{preview}", f"{len(candidates)} 件一致しました。index で指定してください:\n{preview}"),
78
+ )
79
+ target_range = candidates[0]
80
+
81
+ start, end = target_range
82
+ removed_lines = lines[start:end]
83
+ new_lines = lines[:start] + lines[end:]
84
+ new_content = "\n".join(new_lines)
85
+ if raw.endswith("\n") and not new_content.endswith("\n"):
86
+ new_content += "\n"
87
+ if not raw.endswith("\n") and new_content.endswith("\n"):
88
+ new_content = new_content.rstrip("\n")
89
+
90
+ try:
91
+ _write_atomic(path, new_content)
92
+ except Exception as e:
93
+ return _err(loc.t("Save failed", "保存失敗"), loc.t(f"Failed to write {path}: {e}", f"{path} への書き込みに失敗しました: {e}"))
94
+
95
+ ctx.log.info(f"memo-delete: {date_str} {len(removed_lines)} line(s) removed")
96
+
97
+ preview = removed_lines[0].lstrip("- ").strip()
98
+ if len(preview) > 60:
99
+ preview = preview[:57] + "..."
100
+ remaining = len(memo_ranges) - 1
101
+ return {
102
+ "status": "ok",
103
+ "title": loc.t("Deleted", "削除"),
104
+ "summary": loc.t(f"Deleted memo from {date_str}: {preview}", f"{date_str} のメモを削除しました: {preview}"),
105
+ "outputs": {},
106
+ "data": {
107
+ "date": date_str,
108
+ "removed_lines": removed_lines,
109
+ "remaining_memos": remaining,
110
+ "path": path,
111
+ },
112
+ "suggestions": [],
113
+ }
114
+
115
+
116
+ def _collect_memo_ranges(lines):
117
+ ranges = []
118
+ i = 0
119
+ while i < len(lines):
120
+ if _MEMO_LINE_RE.match(lines[i]):
121
+ start = i
122
+ j = i + 1
123
+ while j < len(lines) and _CONTINUATION_RE.match(lines[j]):
124
+ j += 1
125
+ ranges.append((start, j))
126
+ i = j
127
+ else:
128
+ i += 1
129
+ return ranges
130
+
131
+
132
+ def _write_atomic(path, content):
133
+ os.makedirs(os.path.dirname(path), exist_ok=True)
134
+ fd, tmp = tempfile.mkstemp(prefix=".memo.", suffix=".md.tmp", dir=os.path.dirname(path))
135
+ try:
136
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
137
+ f.write(content)
138
+ os.replace(tmp, path)
139
+ except Exception:
140
+ try:
141
+ os.unlink(tmp)
142
+ except Exception:
143
+ pass
144
+ raise
145
+
146
+
147
+ def _err(title, summary):
148
+ return {
149
+ "status": "error",
150
+ "title": title,
151
+ "summary": summary,
152
+ "outputs": {},
153
+ "data": {},
154
+ "suggestions": [],
155
+ }
@@ -0,0 +1,57 @@
1
+ # Builtin: memo-delete
2
+ # デイリーノートの memo 行を削除する。memo-save と対称。
3
+
4
+ id: memo-delete
5
+ name: Memo Delete
6
+ name_i18n:
7
+ en: Memo Delete
8
+ ja: メモ削除
9
+ description: メモの行を削除する
10
+ description_i18n:
11
+ en: Delete a memo line from the daily note
12
+ ja: メモの行を削除する
13
+ runtime: python
14
+ output_mode: raw
15
+ side_effect: true
16
+
17
+ invocation:
18
+ command: memo.delete
19
+ phrases:
20
+ - メモを消して
21
+ - メモ削除
22
+ - さっきのメモを消して
23
+ phrases_i18n:
24
+ en:
25
+ - delete memo
26
+ - remove that memo
27
+ - delete the previous memo
28
+ ja:
29
+ - メモを消して
30
+ - メモ削除
31
+ - さっきのメモを消して
32
+
33
+ input:
34
+ schema:
35
+ type: object
36
+ additionalProperties: false
37
+ properties:
38
+ date:
39
+ type: string
40
+ description: "対象日 (YYYY-MM-DD)。省略時は今日"
41
+ description_i18n:
42
+ en: "Target date (YYYY-MM-DD). Defaults to today"
43
+ ja: "対象日 (YYYY-MM-DD)。省略時は今日"
44
+ match:
45
+ type: string
46
+ minLength: 1
47
+ description: メモ行に含まれる文字列で特定する
48
+ description_i18n:
49
+ en: Text contained in the memo line to identify
50
+ ja: メモ行に含まれる文字列で特定する
51
+ index:
52
+ type: integer
53
+ minimum: 1
54
+ description: 該当日メモ行の1始まり番号で特定する
55
+ description_i18n:
56
+ en: 1-based memo line number for the target day
57
+ ja: 該当日メモ行の1始まり番号で特定する