agent-sin 0.1.11 → 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 +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 +179 -0
  11. package/builtin-skills/memo-list/skill.yaml +51 -0
  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 +10 -3
  42. package/dist/core/chat-engine.js +1563 -197
  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 +13 -0
  73. package/dist/discord/bot.js +542 -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 +122 -31
  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
@@ -0,0 +1,179 @@
1
+ """Builtin: memo-list
2
+
3
+ notes/memo/*.md を更新日時の新しい順で列挙する。
4
+ タイトル(=ファイル名のstem)+本文先頭60文字+更新日時を返す。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import sys
12
+ from datetime import datetime
13
+ from pathlib import Path
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
+ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?", re.DOTALL)
20
+ HASH_TAG_LINE_RE = re.compile(r"^\s*(?:#\S+\s*)+$")
21
+
22
+
23
+ async def run(ctx, input):
24
+ loc = localizer(input)
25
+ args = input.get("args", {}) or {}
26
+ notes_dir = input.get("sources", {}).get("notes_dir", "")
27
+ if not notes_dir:
28
+ return _err(loc.t("Notes unavailable", "ノート不明"), loc.t("notes_dir is unavailable.", "notes_dir が取得できません"))
29
+
30
+ limit = int(args.get("limit", 20))
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)
59
+
60
+ if not shown:
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})")
70
+ else:
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:
116
+ return compact
117
+ return compact[:57] + "..."
118
+
119
+
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):
157
+ return {
158
+ "status": "ok",
159
+ "title": title,
160
+ "summary": summary,
161
+ "outputs": {},
162
+ "data": {
163
+ "memos": memos,
164
+ "count": len(memos),
165
+ "total": total if total is not None else len(memos),
166
+ },
167
+ "suggestions": [],
168
+ }
169
+
170
+
171
+ def _err(title, summary):
172
+ return {
173
+ "status": "error",
174
+ "title": title,
175
+ "summary": summary,
176
+ "outputs": {},
177
+ "data": {},
178
+ "suggestions": [],
179
+ }
@@ -0,0 +1,51 @@
1
+ # Builtin: memo-list
2
+ # notes/memo/ 配下のメモファイルを更新日時の新しい順で一覧表示する。
3
+
4
+ id: memo-list
5
+ name: Memo List
6
+ name_i18n:
7
+ en: Memo List
8
+ ja: メモ一覧
9
+ description: 最近更新したMarkdownメモを一覧表示する
10
+ description_i18n:
11
+ en: List recently updated Markdown memos
12
+ ja: 最近更新したMarkdownメモを一覧表示する
13
+ runtime: python
14
+ output_mode: raw
15
+
16
+ invocation:
17
+ command: memo.list
18
+ phrases:
19
+ - メモ一覧
20
+ - メモを見せて
21
+ - 最近のメモ
22
+ phrases_i18n:
23
+ en:
24
+ - list memos
25
+ - show memos
26
+ - recent memos
27
+ ja:
28
+ - メモ一覧
29
+ - メモを見せて
30
+ - 最近のメモ
31
+
32
+ input:
33
+ schema:
34
+ type: object
35
+ additionalProperties: false
36
+ properties:
37
+ limit:
38
+ type: integer
39
+ minimum: 1
40
+ maximum: 100
41
+ default: 20
42
+ description: 表示する最大件数
43
+ description_i18n:
44
+ en: Maximum number of memos to show
45
+ ja: 表示する最大件数
46
+ required: []
47
+
48
+ memory:
49
+ namespace: memo
50
+ read: true
51
+ write: false
@@ -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:
@@ -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