SLOPSHOPPER

send-confirm

gmail-unifierの送信直前に宛先・件名・本文・添付サイズを出し、ボタンひとつで送信を承認する

guardprompt
A shopper browsing a rack in a slop shop
README

obsidian-ai-knowledge-lake

Obsidian の保管庫を「AI と共有する自分のナレッジレイク」にして、作業の多くを AI(Claude Code / Codex)に任せられるようにするための一式。 作者が自分の保管庫で実際に運用している仕組みから、個人の中身を抜いて汎用化したもの。

中身

フォルダ何か最初に読むもの
knowledge-lake-kit/保管庫の器。 フォルダ構成、AI への指示書(AGENTS.md)、各フォルダの README、テンプレート、運用の考え方START_HERE.md(人間)/ SETUP_FOR_AI.md(AI)
agent-tooling/AI に持たせる道具。 スキル(/inbox・/prioritize など)、MCP(Gmail 横断・Google Tasks)、Claude Code の mod、フック、CLI、定期実行README.md(人間)/ FOR_AI.md(AI)

使い方

git clone https://github.com/KaiMiyazawa/obsidian-ai-knowledge-lake.git
  1. 保管庫を作る: knowledge-lake-kit/START_HERE.md を上から順に進める。最後は AI がインタビューしながら保管庫を組み立てる。
  2. 道具を入れる(任意): 保管庫ができて慣れてきたら、保管庫で AI に次のように頼む。 > <クローンした場所>/agent-tooling/FOR_AI.md を読んで、この道具を私の保管庫に入れるのを手伝って

どちらも「AI が読んで、利用者に確認しながら進める」前提で書いてある。全部を一度に入れなくてよい。

注意

  • 秘密の値(トークン・パスワード・Webhook など)は入っていない。作者の個人情報は伏せ字(you@gmail.com、<MAIN_LIST_ID> など)にしてある。
  • 自分の保管庫は private リポジトリで管理する。 このリポジトリを fork して中身を書き足し、公開したままにしない。
  • 道具は作者の環境(macOS、Claude Code・Codex の特定の版)で作ったもの。そのまま動く保証はなく、利用者の環境に合わせて AI と直しながら使う。

ライセンス

MIT。ただし agent-tooling/outside-vault/gtasks-mcp/ は alvinjchoi/gtasks-mcp(MIT)を改造したもので、同フォルダの LICENSE に従う。

Source 1 files
hooks/register.ts 124 lines
1import type { McpToolResult, Register } from 'claude-code'
2
3const SERVER = 'gmail-unifier'
4const PREFIX = `mcp__${SERVER}__`
5const SEND_TOOLS = new Set(['send_message', 'send_reply', 'forward', 'send_draft'])
6
7const SEND = '送信する'
8const CANCEL = '送信しない'
9
10// 02_note/mail-sending-pitfalls.md: 自治体・学校・小さな組織あては合計2MB以下が目安。gmail-unifierの上限は25MB。
11const SMALL_ORG_LIMIT = 2 * 1024 * 1024
12const HARD_LIMIT = 25 * 1024 * 1024
13
14const BODY_PREVIEW = 600
15
16type SendArgs = {
17  to?: string[] | null
18  cc?: string[] | null
19  bcc?: string[] | null
20  subject?: string
21  body?: string | null
22  text?: string
23  html_body?: string | null
24  attachment_paths?: string[] | null
25  include_attachments?: boolean
26  message_handle?: string
27  reply_to_message_handle?: string | null
28  reply_all?: boolean
29  draft_handle?: string
30}
31
32const textOf = (r: McpToolResult) =>
33  r.content.map(b => (b.type === 'text' ? b.text : '')).join('\n').trim()
34
35const list = (xs?: string[] | null) => (xs && xs.length > 0 ? xs.join(', ') : undefined)
36
37const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)}…(以下${s.length - n}文字省略)` : s)
38
39const mb = (bytes: number) => `${(bytes / 1024 / 1024).toFixed(1)}MB`
40
41export const register: Register = on => {
42  // ボタンで承認された呼び出し。classic.PreToolUse でエンジンの許可確認を省く。
43  const approved = new Set<string>()
44
45  // gmail-unifier のサーバー指示「送信前にチャットで許可を取る」を、このダイアログで置き換える。
46  on('prompt.compose', async ($, e, next) => {
47    const composed = await next(e)
48    return {
49      sections: [
50        ...composed.sections,
51        {
52          id: 'send-confirm:policy',
53          scope: 'session',
54          text: 'gmail-unifier の送信系ツール(send_message / send_reply / forward / send_draft)は、呼ぶと send-confirm mod が宛先・件名・本文・添付を示したダイアログで本人の承認を取る。送信内容が固まったら、チャットで事前の許可を求めずにそのまま送信ツールを呼んでよい。拒否された場合は理由に従って直す。',
55        },
56      ],
57    }
58  })
59
60  on('tool.call', async ($, e, next) => {
61    const tool = String(e.tool)
62    const name = tool.slice(PREFIX.length)
63    if (!tool.startsWith(PREFIX) || !SEND_TOOLS.has(name)) return next(e)
64
65    const args = e as unknown as SendArgs
66    const lines: string[] = []
67
68    if (name === 'send_draft' && args.draft_handle) {
69      const draft = await $.mcp.call(SERVER, 'get_draft', { draft_handle: args.draft_handle })
70      lines.push('【下書きを送信】', clip(textOf(draft), BODY_PREVIEW * 2))
71    } else {
72      const origin = args.message_handle ?? args.reply_to_message_handle
73      if (origin) {
74        const original = await $.mcp.call(SERVER, 'read_message', { message_handle: origin })
75        const head = textOf(original).split('\n').slice(0, 4).join(' / ')
76        lines.push(`【${name === 'forward' ? '転送' : '返信'}】元メール: ${clip(head, 160)}`)
77      }
78      const to = list(args.to)
79      lines.push(`To: ${to ?? (origin ? '(元メールの差出人)' : '(なし)')}${args.reply_all ? '  ※全員に返信' : ''}`)
80      if (list(args.cc)) lines.push(`Cc: ${list(args.cc)}`)
81      if (list(args.bcc)) lines.push(`Bcc: ${list(args.bcc)}`)
82      if (args.subject) lines.push(`件名: ${args.subject}`)
83      const body = args.body ?? args.text ?? args.html_body ?? ''
84      lines.push('本文:', clip(body, BODY_PREVIEW))
85      if (name === 'forward' && args.include_attachments !== false) lines.push('元メールの添付も付く')
86    }
87
88    let total = 0
89    for (const path of args.attachment_paths ?? []) {
90      const size = await $.fs.stat(path).then(s => s.size, () => undefined)
91      if (size === undefined) return { deny: `send-confirm: 添付が見つからない: ${path}` }
92      total += size
93      lines.push(`添付: ${path.split('/').pop()} (${mb(size)})`)
94    }
95    if (total > HARD_LIMIT) return { deny: `send-confirm: 添付合計 ${mb(total)} が25MBを超えている。圧縮か分割をする。` }
96    if (total > SMALL_ORG_LIMIT) {
97      lines.push(`⚠ 添付合計 ${mb(total)}。自治体・学校・小さな組織あては2MB以下が目安(mail-sending-pitfalls.md)`)
98    }
99
100    const answer = await $.ui
101      .ask(`${lines.join('\n')}\n\nこの内容で送信しますか?`, {
102        header: '送信確認',
103        options: [SEND, CANCEL],
104      })
105      .catch(() => CANCEL)
106
107    if (answer !== SEND) {
108      const reason = answer === CANCEL ? '本人が送信を取り消した。' : `本人の指示: ${answer}`
109      return { deny: `send-confirm: ${reason}` }
110    }
111
112    approved.add(e.tool_use_id)
113    try {
114      return await next(e)
115    } finally {
116      approved.delete(e.tool_use_id)
117    }
118  })
119
120  on('classic.PreToolUse', ($, e, next) =>
121    approved.has(e.tool_use_id) ? { allow: true } : next(e),
122  )
123}
124