agent-sin 0.1.12 → 0.1.15

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 +66 -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 +568 -14
  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,7 +1,7 @@
1
1
  """Builtin: memo-list
2
2
 
3
- memo-save が書き込むデイリーノートからメモ行を読み、削除時に使える
4
- 1始まり番号つきで表示する。
3
+ notes/memo/*.md を更新日時の新しい順で列挙する。
4
+ タイトル(=ファイル名のstem)+本文先頭60文字+更新日時を返す。
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -10,15 +10,14 @@ import os
10
10
  import re
11
11
  import sys
12
12
  from datetime import datetime
13
+ from pathlib import Path
13
14
 
14
15
  sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "_shared"))
15
16
  from i18n import localizer # noqa: E402
16
17
 
17
18
 
18
- _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
19
- _MEMO_LINE_RE = re.compile(r"^-\s+")
20
- _CONTINUATION_RE = re.compile(r"^ \S")
21
- _MEMO_HEAD_RE = re.compile(r"^-\s+(?:\d{4}-\d{2}-\d{2}T\S+\s+)?")
19
+ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
20
+ HASH_TAG_LINE_RE = re.compile(r"^\s*(?:#\S+\s*)+$")
22
21
 
23
22
 
24
23
  async def run(ctx, input):
@@ -28,87 +27,142 @@ async def run(ctx, input):
28
27
  if not notes_dir:
29
28
  return _err(loc.t("Notes unavailable", "ノート不明"), loc.t("notes_dir is unavailable.", "notes_dir が取得できません"))
30
29
 
31
- date_str = str(args.get("date", "")).strip()
32
- if not date_str:
33
- date_str = datetime.now().strftime("%Y-%m-%d")
34
- if not _DATE_RE.match(date_str):
35
- return _err(loc.t("Invalid date", "日付不正"), loc.t("Use YYYY-MM-DD for date.", "date は YYYY-MM-DD 形式で指定してください"))
36
-
37
30
  limit = int(args.get("limit", 20))
38
- year, month, _ = date_str.split("-")
39
- path = os.path.join(notes_dir, year, month, f"{date_str}.md")
40
- if not os.path.exists(path):
41
- return _ok(loc.t("No memos", "メモなし"), loc.t(f"No memos for {date_str}.", f"{date_str} のメモはありません"), [], date_str, path)
42
-
43
- try:
44
- with open(path, "r", encoding="utf-8") as f:
45
- raw = f.read()
46
- except OSError as e:
47
- return _err(loc.t("Cannot read", "読み取り失敗"), loc.t(f"Failed to read {path}: {e}", f"{path} を読み取れませんでした: {e}"))
31
+ memo_dir = Path(notes_dir) / "memo"
32
+ if not memo_dir.exists():
33
+ return _ok(loc.t("No memos", "メモなし"), loc.t("There are no memos yet.", "まだメモはありません"), [])
34
+
35
+ entries = []
36
+ for path in memo_dir.glob("*.md"):
37
+ try:
38
+ stat = path.stat()
39
+ raw = path.read_text(encoding="utf-8", errors="ignore")
40
+ except OSError:
41
+ continue
42
+ updated_iso = _extract_frontmatter_field(raw, "updated") or _extract_frontmatter_field(raw, "created")
43
+ sort_key = _to_timestamp(updated_iso) or stat.st_mtime
44
+ snippet = _first_body_line(raw)
45
+ entries.append(
46
+ {
47
+ "title": path.stem,
48
+ "file": str(path.relative_to(notes_dir)),
49
+ "snippet": snippet,
50
+ "updated": updated_iso or datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
51
+ "_sort": sort_key,
52
+ }
53
+ )
54
+
55
+ entries.sort(key=lambda e: e["_sort"], reverse=True)
56
+ shown = entries[:limit]
57
+ for entry in shown:
58
+ entry.pop("_sort", None)
48
59
 
49
- memos = _collect_memos(raw.splitlines(keepends=False))
50
- shown = memos[:limit]
51
60
  if not shown:
52
- return _ok(loc.t("No memos", "メモなし"), loc.t(f"No memo lines for {date_str}.", f"{date_str} にメモ行はありません"), [], date_str, path)
53
-
54
- lines = [loc.t(f"Memos for {date_str}:", f"{date_str} のメモ:")]
55
- for item in shown:
56
- lines.append(f"{item['index']}. {item['text']}")
57
- if len(memos) > len(shown):
58
- lines.append(loc.t(f"...and {len(memos) - len(shown)} more", f"...他 {len(memos) - len(shown)} 件"))
59
-
60
- title = loc.t(f"{len(memos)} memos", f"メモ {len(memos)}件")
61
- return _ok(title, "\n".join(lines), shown, date_str, path, total=len(memos))
62
-
63
-
64
- def _collect_memos(lines):
65
- items = []
66
- i = 0
67
- while i < len(lines):
68
- if _MEMO_LINE_RE.match(lines[i]):
69
- start = i
70
- body = [_clean_head(lines[i])]
71
- j = i + 1
72
- while j < len(lines) and _CONTINUATION_RE.match(lines[j]):
73
- body.append(lines[j].strip())
74
- j += 1
75
- text = " / ".join(part for part in body if part)
76
- items.append(
77
- {
78
- "index": len(items) + 1,
79
- "line": start + 1,
80
- "text": _snippet(text),
81
- }
82
- )
83
- i = j
61
+ return _ok(loc.t("No memos", "メモなし"), loc.t("There are no memos yet.", "まだメモはありません"), [])
62
+
63
+ header = loc.t(f"Latest {len(shown)} memos:", f"最近のメモ {len(shown)}件:")
64
+ lines = [header]
65
+ for idx, entry in enumerate(shown, start=1):
66
+ rel_time = _relative_time(entry["updated"], loc)
67
+ snippet = entry["snippet"]
68
+ if snippet:
69
+ lines.append(f"{idx}. {entry['title']} {snippet} ({rel_time})")
84
70
  else:
85
- i += 1
86
- return items
87
-
88
-
89
- def _clean_head(line):
90
- return _MEMO_HEAD_RE.sub("", line, count=1).strip()
91
-
92
-
93
- def _snippet(text):
94
- compact = " ".join(str(text).split())
95
- if len(compact) <= 160:
71
+ lines.append(f"{idx}. {entry['title']} ({rel_time})")
72
+ if len(entries) > len(shown):
73
+ lines.append(loc.t(f"...and {len(entries) - len(shown)} more", f"...他 {len(entries) - len(shown)} 件"))
74
+
75
+ title = loc.t(f"{len(entries)} memos", f"メモ {len(entries)}件")
76
+ return _ok(title, "\n".join(lines), shown, total=len(entries))
77
+
78
+
79
+ def _extract_frontmatter_field(raw: str, field: str):
80
+ match = FRONTMATTER_RE.match(raw)
81
+ if not match:
82
+ return None
83
+ block = match.group(1)
84
+ for line in block.splitlines():
85
+ if not line.strip():
86
+ continue
87
+ key, _, val = line.partition(":")
88
+ if key.strip() == field:
89
+ v = val.strip()
90
+ if (v.startswith("\"") and v.endswith("\"")) or (v.startswith("'") and v.endswith("'")):
91
+ v = v[1:-1]
92
+ return v or None
93
+ return None
94
+
95
+
96
+ def _first_body_line(raw: str) -> str:
97
+ body = raw
98
+ match = FRONTMATTER_RE.match(raw)
99
+ if match:
100
+ body = raw[match.end():]
101
+ for line in body.splitlines():
102
+ text = line.strip()
103
+ if not text:
104
+ continue
105
+ if HASH_TAG_LINE_RE.match(text):
106
+ continue
107
+ if text.startswith("#") and not text.startswith("##"):
108
+ continue
109
+ return _snippet(text)
110
+ return ""
111
+
112
+
113
+ def _snippet(text: str) -> str:
114
+ compact = " ".join(text.split())
115
+ if len(compact) <= 60:
96
116
  return compact
97
- return compact[:157] + "..."
117
+ return compact[:57] + "..."
98
118
 
99
119
 
100
- def _ok(title, summary, memos, date_str, path, total=None):
120
+ def _parse_datetime(iso: str):
121
+ if not iso:
122
+ return None
123
+ text = iso.replace("Z", "+00:00")
124
+ if " " in text and "T" not in text:
125
+ text = text.replace(" ", "T", 1)
126
+ try:
127
+ return datetime.fromisoformat(text)
128
+ except (ValueError, TypeError):
129
+ return None
130
+
131
+
132
+ def _to_timestamp(iso: str):
133
+ dt = _parse_datetime(iso)
134
+ return dt.timestamp() if dt else None
135
+
136
+
137
+ def _relative_time(iso: str, loc) -> str:
138
+ when = _parse_datetime(iso)
139
+ if when is None:
140
+ return iso or ""
141
+ now = datetime.now().astimezone()
142
+ if when.tzinfo is None:
143
+ when = when.astimezone()
144
+ delta = (now - when).total_seconds()
145
+ if delta < 60:
146
+ return loc.t("just now", "たった今")
147
+ if delta < 3600:
148
+ return loc.t(f"{int(delta // 60)} min ago", f"{int(delta // 60)}分前")
149
+ if delta < 86400:
150
+ return loc.t(f"{int(delta // 3600)}h ago", f"{int(delta // 3600)}時間前")
151
+ if delta < 86400 * 7:
152
+ return loc.t(f"{int(delta // 86400)}d ago", f"{int(delta // 86400)}日前")
153
+ return when.strftime("%Y-%m-%d")
154
+
155
+
156
+ def _ok(title, summary, memos, total=None):
101
157
  return {
102
158
  "status": "ok",
103
159
  "title": title,
104
160
  "summary": summary,
105
161
  "outputs": {},
106
162
  "data": {
107
- "date": date_str,
108
163
  "memos": memos,
109
164
  "count": len(memos),
110
- "total": len(memos) if total is None else total,
111
- "path": path,
165
+ "total": total if total is not None else len(memos),
112
166
  },
113
167
  "suggestions": [],
114
168
  }
@@ -1,15 +1,15 @@
1
1
  # Builtin: memo-list
2
- # memo-save が書き込むデイリーノートのメモ行を一覧表示する。
2
+ # notes/memo/ 配下のメモファイルを更新日時の新しい順で一覧表示する。
3
3
 
4
4
  id: memo-list
5
5
  name: Memo List
6
6
  name_i18n:
7
7
  en: Memo List
8
8
  ja: メモ一覧
9
- description: 指定日のMarkdownメモを一覧表示する
9
+ description: 最近更新したMarkdownメモを一覧表示する
10
10
  description_i18n:
11
- en: List Markdown memos for a date
12
- ja: 指定日のMarkdownメモを一覧表示する
11
+ en: List recently updated Markdown memos
12
+ ja: 最近更新したMarkdownメモを一覧表示する
13
13
  runtime: python
14
14
  output_mode: raw
15
15
 
@@ -18,32 +18,26 @@ invocation:
18
18
  phrases:
19
19
  - メモ一覧
20
20
  - メモを見せて
21
- - 今日のメモ
21
+ - 最近のメモ
22
22
  phrases_i18n:
23
23
  en:
24
24
  - list memos
25
25
  - show memos
26
- - today's memos
26
+ - recent memos
27
27
  ja:
28
28
  - メモ一覧
29
29
  - メモを見せて
30
- - 今日のメモ
30
+ - 最近のメモ
31
31
 
32
32
  input:
33
33
  schema:
34
34
  type: object
35
35
  additionalProperties: false
36
36
  properties:
37
- date:
38
- type: string
39
- description: "対象日 (YYYY-MM-DD)。省略時は今日"
40
- description_i18n:
41
- en: "Target date (YYYY-MM-DD). Defaults to today"
42
- ja: "対象日 (YYYY-MM-DD)。省略時は今日"
43
37
  limit:
44
38
  type: integer
45
39
  minimum: 1
46
- maximum: 50
40
+ maximum: 100
47
41
  default: 20
48
42
  description: 表示する最大件数
49
43
  description_i18n:
@@ -1,19 +1,15 @@
1
1
  """Builtin: memo-save
2
2
 
3
- 入力 text を Markdown メモのデイリーファイルに追記する。複数行に対応し、
4
- 1メモ = 1バレット + 継続行(2スペースインデント)の Markdown リスト形式で書き出す。
5
- Runtime outputs.note skill.yaml の outputs[id=note] に従って保存するため、
6
- このスキル自身は **content を返すだけ**でファイルには触らない。
7
-
8
- 入力:
9
- args.text: 保存する本文 (必須, 改行可)
10
- 出力:
11
- outputs.note: 追記用の Markdown。保存先の表示は skill.yaml 側で抑制する。
3
+ 1メモ=1ファイル方式で notes/memo/{title}.md に保存する。
4
+ - AI でタイトルを生成し、サニタイズしてファイル名にする
5
+ - 同名既存ファイルがあれば本文末尾に追記し、updated を更新する (runtime update_or_append)
6
+ - 旧バレット形式は廃止
12
7
  """
13
8
 
14
9
  from __future__ import annotations
15
10
 
16
11
  import os
12
+ import re
17
13
  import sys
18
14
  from datetime import datetime
19
15
 
@@ -21,6 +17,42 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(
21
17
  from i18n import localizer # noqa: E402
22
18
 
23
19
 
20
+ MAX_TAGS = 3
21
+ MAX_TITLE_LEN = 48
22
+ GENERIC_TAGS = {
23
+ "memo",
24
+ "memos",
25
+ "note",
26
+ "notes",
27
+ "daily",
28
+ "メモ",
29
+ "ノート",
30
+ "日記",
31
+ }
32
+ KEYWORD_TAGS = [
33
+ ("agent-sin", ("agent-sin", "agent sin", "agentsin")),
34
+ ("kingcoding", ("kingcoding", "king coding", "king-coding")),
35
+ ("obsidian", ("obsidian",)),
36
+ ("ai", ("ai", "openai", "codex", "gpt", "claude", "gemini", "ollama", "llm", "生成ai", "生成AI")),
37
+ ("仕事", ("仕事", "業務", "プロジェクト", "案件", "顧客", "client", "project")),
38
+ ("会議", ("会議", "打ち合わせ", "ミーティング", "mtg", "meeting")),
39
+ ("todo", ("todo", "タスク", "やること", "対応", "締切", "deadline")),
40
+ ("アイデア", ("アイデア", "idea", "思いつき", "案", "企画")),
41
+ ("バグ", ("バグ", "不具合", "エラー", "修正", "bug", "error", "fix")),
42
+ ("学習", ("学習", "勉強", "読書", "本", "study", "learning", "book")),
43
+ ("健康", ("健康", "体調", "睡眠", "運動", "散歩", "病院", "health")),
44
+ ("お金", ("お金", "支払い", "請求", "家計", "税金", "money", "invoice")),
45
+ ("予定", ("予定", "予約", "スケジュール", "schedule", "appointment")),
46
+ ("買い物", ("買い物", "購入", "買う", "shopping", "buy")),
47
+ ("食事", ("食事", "昼食", "夕食", "ランチ", "店", "restaurant", "food")),
48
+ ("生活", ("生活", "掃除", "洗濯", "家事", "home")),
49
+ ("旅行", ("旅行", "ホテル", "航空券", "travel", "hotel", "flight")),
50
+ ]
51
+ HASH_TAG_RE = re.compile(r"(?<!\S)#([^\s#]+)")
52
+ FORBIDDEN_FILENAME_RE = re.compile(r"[\\/:*?\"<>|#\^\[\]]")
53
+ CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
54
+
55
+
24
56
  async def run(ctx, input):
25
57
  loc = localizer(input)
26
58
  args = input.get("args", {})
@@ -38,37 +70,171 @@ async def run(ctx, input):
38
70
  }
39
71
 
40
72
  ctx.log.info(f"memo-save: saving {len(text)} chars")
41
- timestamp = input.get("trigger", {}).get("time") or datetime.now().isoformat()
42
- content = format_memo(timestamp, text)
73
+ timestamp = input.get("trigger", {}).get("time") or datetime.now().astimezone().isoformat()
74
+ tags = build_tags(text, args.get("tags"), args.get("auto_tags", True) is not False)
75
+
76
+ explicit_title = str(args.get("title", "")).strip()
77
+ raw_title = explicit_title or await generate_title(ctx, text)
78
+ sanitized = sanitize_filename(raw_title) or fallback_title(timestamp)
79
+ filename = f"{sanitized}.md"
80
+
81
+ content = format_memo_body(text, tags)
82
+ iso_now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
43
83
 
44
84
  return {
45
85
  "status": "ok",
46
86
  "title": loc.t("Saved", "保存しました"),
47
- "summary": loc.t("Memo saved.", "メモを保存しました"),
87
+ "summary": loc.t(f"Memo saved as {filename}.", f"メモを保存しました: {filename}"),
48
88
  "outputs": {
49
89
  "note": {
90
+ "filename": filename,
50
91
  "content": content,
51
92
  "frontmatter": {
52
- "tags": ["memo"]
93
+ "tags": ["memo"] + [t for t in tags if t not in ("memo",)],
94
+ "created": iso_now,
95
+ "updated": iso_now,
53
96
  },
54
97
  }
55
98
  },
56
99
  "data": {
57
- "length": len(text)
100
+ "title": sanitized,
101
+ "filename": filename,
102
+ "length": len(text),
103
+ "tags": tags,
58
104
  },
59
105
  "suggestions": [],
60
106
  }
61
107
 
62
108
 
63
- def format_memo(timestamp, text):
64
- # 改行を正規化し、空行はメモ区切りと衝突するため除外する
65
- raw_lines = [line.rstrip() for line in text.replace("\r\n", "\n").split("\n")]
66
- body_lines = [line for line in raw_lines if line.strip()]
67
- if not body_lines:
109
+ async def generate_title(ctx, text: str) -> str:
110
+ if os.environ.get("AGENT_SIN_FAKE_PROVIDER") == "1":
111
+ return fallback_from_text(text)
112
+ prompt = build_title_prompt(text)
113
+ try:
114
+ result = await ctx.ai.run("title", prompt)
115
+ except Exception as e:
116
+ ctx.log.warn(f"memo-save: title generation failed: {e}")
117
+ return fallback_from_text(text)
118
+ if not isinstance(result, dict) or result.get("status") != "ok":
119
+ reason = (result or {}).get("reason") if isinstance(result, dict) else None
120
+ ctx.log.warn(f"memo-save: title generation returned no result: {reason}")
121
+ return fallback_from_text(text)
122
+ raw = str(result.get("text") or "").strip()
123
+ cleaned = strip_quotes_and_lines(raw)
124
+ if not cleaned:
125
+ return fallback_from_text(text)
126
+ return cleaned
127
+
128
+
129
+ def build_title_prompt(text: str) -> str:
130
+ truncated = text if len(text) <= 600 else (text[:600] + "...")
131
+ return (
132
+ "次のメモ本文から、24文字以下の短いタイトルを1つだけ返してください。\n"
133
+ "引用符・カギ括弧・句読点・絵文字・説明文は付けず、タイトル本文のみを1行で返してください。\n"
134
+ "本文の言語に合わせて日本語/英語で返してください。\n\n"
135
+ f"本文:\n{truncated}"
136
+ )
137
+
138
+
139
+ def strip_quotes_and_lines(text: str) -> str:
140
+ first_line = text.splitlines()[0] if text else ""
141
+ return first_line.strip().strip("「」『』\"'`").strip()
142
+
143
+
144
+ def fallback_from_text(text: str) -> str:
145
+ head = text.strip().splitlines()[0] if text.strip() else ""
146
+ if len(head) > 40:
147
+ head = head[:40]
148
+ return head
149
+
150
+
151
+ def fallback_title(timestamp: str) -> str:
152
+ dt = parse_datetime(timestamp) or datetime.now().astimezone()
153
+ return f"untitled-{dt.strftime('%H%M%S')}"
154
+
155
+
156
+ def sanitize_filename(raw: str) -> str:
157
+ if not raw:
158
+ return ""
159
+ cleaned = CONTROL_CHARS_RE.sub("", raw)
160
+ cleaned = FORBIDDEN_FILENAME_RE.sub("-", cleaned)
161
+ cleaned = cleaned.replace("\n", " ").replace("\r", " ")
162
+ cleaned = re.sub(r"\s+", " ", cleaned).strip()
163
+ cleaned = cleaned.strip("._- ")
164
+ if not cleaned:
68
165
  return ""
69
- first, *rest = body_lines
70
- head = f"- {timestamp} {first}"
71
- if not rest:
72
- return head + "\n"
73
- indented = "\n".join(f" {line}" for line in rest)
74
- return f"{head}\n{indented}\n"
166
+ if len(cleaned) > MAX_TITLE_LEN:
167
+ cleaned = cleaned[:MAX_TITLE_LEN].rstrip("._- ")
168
+ if cleaned in (".", ".."):
169
+ return ""
170
+ return cleaned
171
+
172
+
173
+ def format_memo_body(text: str, tags) -> str:
174
+ body = text.rstrip()
175
+ if tags:
176
+ tag_line = " ".join(f"#{tag}" for tag in tags)
177
+ return f"{body}\n\n{tag_line}\n"
178
+ return f"{body}\n"
179
+
180
+
181
+ def build_tags(text, explicit_tags=None, auto_tags=True):
182
+ tags = []
183
+ for tag in parse_tag_values(explicit_tags):
184
+ add_tag(tags, tag)
185
+ for tag in extract_hash_tags(text):
186
+ add_tag(tags, tag)
187
+ if auto_tags:
188
+ haystack = text.lower()
189
+ for tag, keywords in KEYWORD_TAGS:
190
+ if any(str(keyword).lower() in haystack for keyword in keywords):
191
+ add_tag(tags, tag)
192
+ if len(tags) >= MAX_TAGS:
193
+ break
194
+ return tags[:MAX_TAGS]
195
+
196
+
197
+ def parse_tag_values(value):
198
+ if value is None:
199
+ return []
200
+ if isinstance(value, list):
201
+ return value
202
+ if isinstance(value, str):
203
+ return [part for part in re.split(r"[,、\s]+", value) if part.strip()]
204
+ return [value]
205
+
206
+
207
+ def extract_hash_tags(text):
208
+ return [match.group(1) for match in HASH_TAG_RE.finditer(text)]
209
+
210
+
211
+ def add_tag(tags, value):
212
+ tag = normalize_tag(value)
213
+ if not tag or tag in tags:
214
+ return
215
+ tags.append(tag)
216
+
217
+
218
+ def normalize_tag(value):
219
+ tag = str(value).strip().lstrip("##")
220
+ if not tag:
221
+ return None
222
+ tag = re.sub(r"\s+", "-", tag)
223
+ tag = re.sub(r"[^\w\-/一-龯ぁ-んァ-ヶー]+", "", tag, flags=re.UNICODE)
224
+ tag = tag.strip("-_/")
225
+ if not tag or tag.isdigit():
226
+ return None
227
+ if re.fullmatch(r"[A-Za-z0-9_\-/]+", tag):
228
+ tag = tag.lower()
229
+ if tag.lower() in GENERIC_TAGS:
230
+ return None
231
+ return tag[:32]
232
+
233
+
234
+ def parse_datetime(value):
235
+ if not value:
236
+ return None
237
+ try:
238
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
239
+ except ValueError:
240
+ return None
@@ -1,7 +1,7 @@
1
1
  # Builtin: memo-save
2
2
  # 会話の skill-call または agent-sin run memo-save --text "..." で呼ばれる。
3
- # Markdown のデイリーノートに追記する低リスクスキル。複数行テキストにも対応する。
4
- # ユーザースキル著作のリファレンスとしても読まれる。詳細は https://agent.shingoirie.com/skill-authoring を参照。
3
+ # 1メモ=1ファイル形式で notes/memo/{title}.md に保存する低リスクスキル。
4
+ # AIで短いタイトルを生成し、同名既存ファイルがあれば末尾に追記して updated を更新する。
5
5
 
6
6
  id: memo-save
7
7
  name: Memo Save
@@ -35,15 +35,39 @@ input:
35
35
  properties:
36
36
  text:
37
37
  type: string
38
+ title:
39
+ type: string
40
+ description: Optional explicit title. When provided, used as the filename without AI generation.
41
+ description_i18n:
42
+ en: Optional explicit title. When provided, used as the filename without AI generation.
43
+ ja: 任意のタイトル。指定するとAIタイトル生成をスキップしてそのまま使う
44
+ tags:
45
+ description: Optional Obsidian tags for this memo. Use 1-3 short tags without leading #, based on the memo content.
46
+ description_i18n:
47
+ en: Optional Obsidian tags for this memo. Use 1-3 short tags without leading #, based on the memo content.
48
+ ja: メモ内容に合うObsidianタグ。#なしで1〜3個まで。
49
+ auto_tags:
50
+ type: boolean
51
+ default: true
52
+ description: Add simple content-based tags when tags are not enough.
53
+ description_i18n:
54
+ en: Add simple content-based tags when tags are not enough.
55
+ ja: 本文から簡単なタグを補完する
38
56
  required:
39
57
  - text
40
58
 
59
+ ai_steps:
60
+ - id: title
61
+ purpose: Generate a short title (within 24 characters, no quotes or punctuation) summarizing the memo body. Return only the title text on a single line.
62
+ model: chat
63
+ optional: true
64
+
41
65
  outputs:
42
66
  - id: note
43
67
  type: markdown
44
- path: notes/{{yyyy}}/{{MM}}
45
- filename: "{{date}}.md"
46
- append: true
68
+ path: notes/memo
69
+ filename: "untitled.md"
70
+ merge_mode: update_or_append
47
71
  show_saved: false
48
72
 
49
73
  memory: