@clubs.place/cli 0.0.0-stage → 0.1.17
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.
- package/.env.example +88 -0
- package/LICENSE +21 -0
- package/PROVENANCE.json +77 -0
- package/README.md +70 -2
- package/bin/clubs-nippo +7 -0
- package/bin/clubs-nippo-web.mjs +338 -0
- package/bin/clubs-nippo.mjs +527 -0
- package/docs/events-schema.md +76 -0
- package/examples/hermes-events.sample.jsonl +8 -0
- package/package.json +48 -4
- package/src/aggregate.mjs +105 -0
- package/src/autorun.mjs +25 -0
- package/src/autorun.test.mjs +43 -0
- package/src/cards.mjs +406 -0
- package/src/collect.mjs +111 -0
- package/src/config.mjs +173 -0
- package/src/generators/claude.mjs +31 -0
- package/src/generators/cli.mjs +81 -0
- package/src/generators/codex.mjs +28 -0
- package/src/generators/copilot.mjs +21 -0
- package/src/generators/gemini.mjs +20 -0
- package/src/generators/openai-compat.mjs +79 -0
- package/src/generators/openrouter.mjs +65 -0
- package/src/history.mjs +66 -0
- package/src/ingest.mjs +78 -0
- package/src/lemma.docHash.test.mjs +37 -0
- package/src/lemma.mjs +303 -0
- package/src/mask.mjs +45 -0
- package/src/mcp.mjs +269 -0
- package/src/narrative.mjs +191 -0
- package/src/prompt.mjs +184 -0
- package/src/prompt.test.mjs +146 -0
- package/src/remote-config.mjs +72 -0
- package/src/render.mjs +467 -0
- package/src/send.mjs +36 -0
- package/src/settings.mjs +54 -0
- package/src/sources/claude-code.mjs +174 -0
- package/src/sources/codex-cli.mjs +182 -0
- package/src/sources/copilot-cli.mjs +125 -0
- package/src/sources/cowork.mjs +193 -0
- package/src/sources/events-file.mjs +120 -0
- package/src/sources/gemini-cli.mjs +128 -0
- package/src/sources/hermes.mjs +427 -0
- package/src/store.mjs +227 -0
- package/src/tasks.mjs +229 -0
- package/src/tool-name.mjs +28 -0
- package/templates/report-v3.reference.html +162 -0
- package/web/assets/logo-clubs-white.png +0 -0
- package/web/assets/logo-clubs-white.svg +7 -0
- package/web/assets/logo-clubs-white.svg.tmp +30 -0
- package/web/assets/logo-clubs.svg +30 -0
- package/web/assets/qr.js +411 -0
- package/web/assets/sidebar.js +154 -0
- package/web/assets/twemoji.js +34 -0
- package/web/cards/achievements.html +230 -0
- package/web/cards/catalog.json +217 -0
- package/web/email/hero-v3.reference.html +123 -0
- package/web/home/index.html +269 -0
- package/web/preview.json +301 -0
- package/web/qr-selftest.html +46 -0
- package/web/reports/edit.html +255 -0
- package/web/reports/edit.reference.html +257 -0
- package/web/reports/index.html +196 -0
- package/web/settings/index.html +219 -0
- package/web/share/cards-reference.html +228 -0
- package/web/share/passport.html +214 -0
- package/web/share/teianbako.html +177 -0
- package/web/signup/index.html +326 -0
- package/web/tasks/index.html +229 -0
- package/web/verify/index.html +248 -0
package/src/render.mjs
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// clubs-nippo — AI業務日報 (CLUBS)
|
|
2
|
+
// Copyright (c) 2026 FRAME00 Inc.
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// https://github.com/frame00/clubs-nippo
|
|
5
|
+
|
|
6
|
+
import { hostname } from "node:os";
|
|
7
|
+
import { REDACT_TOKEN } from "./mask.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* メール用の HTML を組み立てる。
|
|
11
|
+
*
|
|
12
|
+
* メールクライアント(特に Gmail)で使えないものは使わない:
|
|
13
|
+
* - CSS カスタムプロパティ(var())… Gmail は解釈せず、その宣言ごと捨てる
|
|
14
|
+
* - position:absolute / 負のマージン … Gmail は position を落とす
|
|
15
|
+
* - ::before / ::after … Gmail は疑似要素を落とす
|
|
16
|
+
* - <style> への依存 … クライアントによっては丸ごと消える
|
|
17
|
+
*
|
|
18
|
+
* したがって色・寸法はすべて style 属性に literal で焼き込む。
|
|
19
|
+
* 見た目の基準(CLUBSデザインシステム)は templates/report-v3.reference.html。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// CLUBS デザインシステムのトークン(reference から実測)
|
|
23
|
+
const T = {
|
|
24
|
+
bg: "#F5F6FB",
|
|
25
|
+
paper: "#FFFFFF",
|
|
26
|
+
ink: "#151827",
|
|
27
|
+
ink2: "#5B607A",
|
|
28
|
+
line: "#E7E9F2",
|
|
29
|
+
brand1: "#4F6BF6",
|
|
30
|
+
brand2: "#8B5CF6",
|
|
31
|
+
ok: "#0A8F66",
|
|
32
|
+
okT: "#E4F5EE",
|
|
33
|
+
gold: "#B7862B",
|
|
34
|
+
goldT: "#F7EFDD",
|
|
35
|
+
av1: "#66B9D8",
|
|
36
|
+
av2: "#3E93B5",
|
|
37
|
+
body: "'Inter','Noto Sans JP','Hiragino Sans','Yu Gothic',system-ui,sans-serif",
|
|
38
|
+
mono: "'IBM Plex Mono',ui-monospace,monospace",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const esc = (s) =>
|
|
42
|
+
String(s ?? "")
|
|
43
|
+
.replaceAll("&", "&")
|
|
44
|
+
.replaceAll("<", "<")
|
|
45
|
+
.replaceAll(">", ">")
|
|
46
|
+
.replaceAll('"', """);
|
|
47
|
+
|
|
48
|
+
const withRedactions = (s) =>
|
|
49
|
+
esc(s).replaceAll(
|
|
50
|
+
esc(REDACT_TOKEN),
|
|
51
|
+
`<span style="background:#2A3145;color:#2A3145;border-radius:4px;padding:0 5px;letter-spacing:-1px">██████</span>`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const countRedactions = (s) => String(s ?? "").split(REDACT_TOKEN).length - 1;
|
|
55
|
+
|
|
56
|
+
const jpDate = (iso) => {
|
|
57
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
58
|
+
const wd = ["日", "月", "火", "水", "木", "金", "土"][new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
|
|
59
|
+
return { full: `${y}年${m}月${d}日(${wd})`, short: `${m}/${d}` };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ── 部品 ────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/** 角丸カード。td に直接スタイルを当てる(div の border-radius を落とすクライアント対策) */
|
|
65
|
+
const card = (inner, pad = "16px 18px") => `
|
|
66
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;margin-bottom:10px">
|
|
67
|
+
<tr><td style="background:${T.paper};border:1px solid ${T.line};border-radius:14px;padding:${pad}">${inner}</td></tr>
|
|
68
|
+
</table>`;
|
|
69
|
+
|
|
70
|
+
/** ✓ の丸印。Gmail でも出るよう span + border-radius で作る */
|
|
71
|
+
const checkMark = (color) =>
|
|
72
|
+
`<span style="display:inline-block;width:14px;height:14px;line-height:14px;border-radius:7px;background:${color};color:#fff;font-size:9px;text-align:center">✓</span>`;
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
// 照合状態はプロフィール右上と最下部の証明欄の2か所だけに出す。
|
|
77
|
+
// 同じ表示をブロックごとに繰り返すと、情報が増えないまま画面だけ埋まるため。
|
|
78
|
+
const workBlocks = (narrative) => {
|
|
79
|
+
if (!narrative.workBlocks?.length) {
|
|
80
|
+
return card(
|
|
81
|
+
`<div style="font-size:13.5px;color:${T.ink}">対象日にツール実行の記録はありませんでした。</div>`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return narrative.workBlocks
|
|
86
|
+
.map((b) => {
|
|
87
|
+
const redactions = countRedactions(b.text);
|
|
88
|
+
const maskNote =
|
|
89
|
+
redactions > 0
|
|
90
|
+
? `<div style="margin-top:6px;font-size:10.5px;color:${T.ink2}"><b style="color:${T.gold}">■ マスク済み ${redactions}箇所</b> — オーナーが指定した語を非表示にしています。</div>`
|
|
91
|
+
: "";
|
|
92
|
+
const metric = b.metric
|
|
93
|
+
? `<div style="margin-top:6px;padding-top:6px;border-top:1px dashed ${T.line};font-family:${T.mono};font-size:11px;color:${T.brand1}">${withRedactions(b.metric)}</div>`
|
|
94
|
+
: "";
|
|
95
|
+
|
|
96
|
+
return card(`
|
|
97
|
+
<div style="font-size:14px;font-weight:800;color:${T.ink}">${withRedactions(b.title)}
|
|
98
|
+
<span style="display:inline-block;font-size:10px;font-weight:600;color:${T.ink2};background:${T.bg};border:1px solid ${T.line};border-radius:999px;padding:1px 8px;margin-left:4px">${esc(b.tool ?? "-")}</span>
|
|
99
|
+
</div>
|
|
100
|
+
<div style="font-size:13.5px;color:${T.ink};line-height:1.85;margin-top:6px">${withRedactions(b.text)}</div>
|
|
101
|
+
${metric}
|
|
102
|
+
${maskNote}`);
|
|
103
|
+
})
|
|
104
|
+
.join("\n");
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const insights = (narrative) => {
|
|
108
|
+
if (!narrative.insights?.length) return "";
|
|
109
|
+
const items = narrative.insights
|
|
110
|
+
.map(
|
|
111
|
+
(i) => `
|
|
112
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;margin-bottom:9px">
|
|
113
|
+
<tr><td style="background:${T.paper};border:1px solid ${T.line};border-radius:12px;padding:12px 13px">
|
|
114
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
115
|
+
<tr>
|
|
116
|
+
<td width="36" valign="top">
|
|
117
|
+
<div style="width:32px;height:32px;line-height:32px;text-align:center;border-radius:9px;background:#EAF4F8;font-size:16px">${esc(i.icon ?? "🔧")}</div>
|
|
118
|
+
</td>
|
|
119
|
+
<td valign="top" style="padding-left:10px">
|
|
120
|
+
<div style="font-size:13px;font-weight:800;color:${T.ink};line-height:1.35">${withRedactions(i.title)}</div>
|
|
121
|
+
<div style="font-size:12px;color:${T.ink2};margin-top:2px;line-height:1.6">${withRedactions(i.desc)}</div>
|
|
122
|
+
${i.save ? `<div style="font-family:${T.mono};font-size:11px;color:${T.ok};font-weight:600;margin-top:3px">▹ ${withRedactions(i.save)}</div>` : ""}
|
|
123
|
+
</td>
|
|
124
|
+
</tr>
|
|
125
|
+
</table>
|
|
126
|
+
</td></tr>
|
|
127
|
+
</table>`,
|
|
128
|
+
)
|
|
129
|
+
.join("\n");
|
|
130
|
+
|
|
131
|
+
return `
|
|
132
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;margin-top:18px">
|
|
133
|
+
<tr><td style="background:#F4F6FF;border:1px solid #D7DEFB;border-radius:16px;padding:16px 18px">
|
|
134
|
+
<div style="font-size:14px;font-weight:800;color:${T.ink}">🔧 本日の気づき・提案
|
|
135
|
+
<span style="display:inline-block;font-size:9.5px;font-weight:800;background:#E7ECFE;color:${T.brand1};border-radius:999px;padding:2px 8px;margin-left:6px">オプトイン</span>
|
|
136
|
+
<span style="display:inline-block;font-size:9.5px;font-weight:800;background:${T.goldT};color:${T.gold};border-radius:999px;padding:2px 8px">承認待ち</span>
|
|
137
|
+
</div>
|
|
138
|
+
<div style="font-size:11.5px;color:${T.ink2};margin:4px 0 12px">AIが自分のログから気づいたことです。<b>実行はしていません。提案だけ</b> ── あなたが承認して、はじめて動きます。</div>
|
|
139
|
+
${items}
|
|
140
|
+
<div style="font-size:10.5px;color:${T.ink2};margin-top:11px;padding-top:9px;border-top:1px solid ${T.line}"><b style="color:${T.brand1}">勝手には動きません。</b>承認した変更だけを実行します。</div>
|
|
141
|
+
</td></tr>
|
|
142
|
+
</table>`;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** 送信時刻(オーナーのタイムゾーン)で挨拶を出し分ける。 */
|
|
146
|
+
const greeting = (tzOffsetMin) => {
|
|
147
|
+
const hour = Number(
|
|
148
|
+
new Date(Date.now() + tzOffsetMin * 60_000).toISOString().slice(11, 13),
|
|
149
|
+
);
|
|
150
|
+
if (hour >= 4 && hour < 11) return "おはようございます。今日も一日よろしくお願いします!";
|
|
151
|
+
if (hour >= 11 && hour < 18) return "こんにちは。昨日ぶんの報告です。今日もよろしくお願いします。";
|
|
152
|
+
return "こんばんは。昨日ぶんの報告をお送りします。おつかれさまです。";
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* フッターの証明バッジ。
|
|
157
|
+
* "Verified with Lemma" は Lemma への登録が成功したときにしか出さない。
|
|
158
|
+
* 未登録のうちにこの文字列を出すと、照合していないものを照合済みと名乗ることになる。
|
|
159
|
+
*
|
|
160
|
+
* リンクにはしない。読み手が踏むべきリンクは本文側の1本(verifyLink)に絞る。
|
|
161
|
+
*/
|
|
162
|
+
const footerProofBadge = (proof) => {
|
|
163
|
+
if (proof.status !== "verified") {
|
|
164
|
+
return `<span style="display:inline-block;font-size:10px;font-weight:700;color:${T.gold};background:${T.goldT};border-radius:999px;padding:3px 10px">Lemma 未登録 — 照合予定</span>`;
|
|
165
|
+
}
|
|
166
|
+
return `<span style="display:inline-block;font-size:10px;background:${T.okT};border-radius:999px;padding:3px 10px"><span style="color:${T.ok};font-weight:800">${checkMark(T.ok)} Verified with Lemma</span></span>`;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 検証ページへのリンク。
|
|
171
|
+
*
|
|
172
|
+
* 出すのは verifyUrl がある(=LEMMA_VERIFY_BASE 設定済み)ときだけ。
|
|
173
|
+
* 検証ページが実データを返せるようになるまで LEMMA_VERIFY_BASE は空にしておく。
|
|
174
|
+
* 空なら registerProof が verifyUrl=null を返すので、リンクは自動的に出ない。
|
|
175
|
+
*
|
|
176
|
+
* 踏んでも「未登録」としか出ないリンクを載せるのは、✓ を出しておいて
|
|
177
|
+
* 裏が取れないのと同じこと。GET /v1/documents/:docHash が本番稼働してから
|
|
178
|
+
* env を1つ入れて有効化する。
|
|
179
|
+
*/
|
|
180
|
+
const verifyLink = (proof) => {
|
|
181
|
+
if (proof.status !== "verified" || !proof.verifyUrl) return "";
|
|
182
|
+
|
|
183
|
+
// txHash があればオンチェーンに記録されている(anchored)。無ければ登録のみ(registered)。
|
|
184
|
+
const label = proof.txHash
|
|
185
|
+
? "オンチェーンの記録を確認する →"
|
|
186
|
+
: "この日報が改ざんされていないか確認する →";
|
|
187
|
+
|
|
188
|
+
return `
|
|
189
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:-4px 0 14px">
|
|
190
|
+
<tr><td align="center">
|
|
191
|
+
<a href="${esc(proof.verifyUrl)}" style="font-size:12px;font-weight:700;color:${T.brand1};text-decoration:none">${label}</a>
|
|
192
|
+
</td></tr>
|
|
193
|
+
</table>`;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 日報の頭に置く横長のデイリーヒーロー。そのままスクショして貼れる帯。
|
|
198
|
+
*
|
|
199
|
+
* 見本は web/email/hero-v3.reference.html。ただしそのままは載せられない。
|
|
200
|
+
* Gmail は CSS変数・position・疑似要素・flexbox を落とすので、<table> と
|
|
201
|
+
* インライン style で組み直す。グラデーションは旧 Outlook が無視するため、
|
|
202
|
+
* 背景色のフォールバックを必ず先に置く。
|
|
203
|
+
*
|
|
204
|
+
* 出す数字は「きのう」。パスポート(累計)とは別物で、撃ち分ける。
|
|
205
|
+
*/
|
|
206
|
+
/**
|
|
207
|
+
* プロフィールのモデル行。
|
|
208
|
+
*
|
|
209
|
+
* ログにモデルが記録されていれば、その内訳を出す。OpenRouter 等で切り替えるエージェント
|
|
210
|
+
* (Hermes など)は1日に複数のモデルを使うので、静的なモデル名を1つ掲げると
|
|
211
|
+
* 「半分の作業を別モデルがやったのに、このモデルがやったことにする」嘘になる。
|
|
212
|
+
*
|
|
213
|
+
* 記録が無ければ MODEL_LABEL、それも無ければ収集元を出す。無いものを在ることにしない。
|
|
214
|
+
*/
|
|
215
|
+
const modelLine = (activity, config) => {
|
|
216
|
+
const models = Object.entries(activity.aggregates.modelCounts ?? {}).sort(
|
|
217
|
+
(a, b) => b[1] - a[1],
|
|
218
|
+
);
|
|
219
|
+
if (models.length === 0) return config.modelLabel || activity.source;
|
|
220
|
+
if (models.length === 1) return models[0][0];
|
|
221
|
+
|
|
222
|
+
const shown = models.slice(0, 3).map(([m, n]) => `${m} ×${n}`);
|
|
223
|
+
const rest = models.length - shown.length;
|
|
224
|
+
return rest > 0 ? `${shown.join(" · ")} ほか${rest}モデル` : shown.join(" · ");
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 統合ヘッダー(ヒーロー1枚)。
|
|
229
|
+
*
|
|
230
|
+
* 以前はこの下に旧トップ(プロフィールカード・集計3タイル・ツールチップ列・稼働時間帯・
|
|
231
|
+
* docHash)が残っていて、名前も数字も二度出ていた。メールが長い原因は本文ではなくここ。
|
|
232
|
+
* 数字はヒーローの1回だけ。docHash は最下部の1回だけ。
|
|
233
|
+
*
|
|
234
|
+
* 稼働時間帯は出さない。AIは変な時間に動くので、対外的に見せる情報ではない(既定方針)。
|
|
235
|
+
*
|
|
236
|
+
* Gmail 制約: <table>+インライン style。var()/flex/position/疑似要素は使わない。
|
|
237
|
+
* ロゴは PNG(Gmail は SVG を描画しない)。URL が無ければテキストで出す。
|
|
238
|
+
*/
|
|
239
|
+
const dailyHero = ({ activity, proof, config, dateLabel }) => {
|
|
240
|
+
const a = activity.aggregates;
|
|
241
|
+
const verified = proof.status === "verified";
|
|
242
|
+
|
|
243
|
+
const logo = config.emailLogoUrl
|
|
244
|
+
? `<img src="${esc(config.emailLogoUrl)}" height="15" alt="CLUBS" style="height:15px;display:inline-block;vertical-align:middle;border:0" />`
|
|
245
|
+
: `<span style="font-size:14px;font-weight:800;color:#fff;letter-spacing:-0.02em;vertical-align:middle">CLUBS</span>`;
|
|
246
|
+
|
|
247
|
+
const badge = verified
|
|
248
|
+
? `<span style="display:inline-block;font-size:10px;font-weight:800;color:#fff;background:rgba(255,255,255,.18);border-radius:999px;padding:3px 10px;white-space:nowrap">✓ ログ照合済み</span>`
|
|
249
|
+
: `<span style="display:inline-block;font-size:10px;font-weight:800;color:#7A5A12;background:${T.goldT};border-radius:999px;padding:3px 10px;white-space:nowrap">照合予定</span>`;
|
|
250
|
+
|
|
251
|
+
const cell = (n, unit, label, color = "#fff") => `
|
|
252
|
+
<td width="25%" align="center" style="padding:0 4px">
|
|
253
|
+
<div style="font-size:22px;font-weight:800;color:${color};line-height:1.1">${esc(n)}<span style="font-size:12px">${esc(unit)}</span></div>
|
|
254
|
+
<div style="font-size:9.5px;font-weight:600;color:rgba(255,255,255,.72);margin-top:3px">${esc(label)}</div>
|
|
255
|
+
</td>`;
|
|
256
|
+
|
|
257
|
+
return `
|
|
258
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate;margin-bottom:16px">
|
|
259
|
+
<tr><td style="background:${T.brand1};background-image:linear-gradient(120deg,${T.brand1},${T.brand2});border-radius:16px;padding:0">
|
|
260
|
+
|
|
261
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
262
|
+
<tr><td style="padding:13px 18px 0">
|
|
263
|
+
${logo}<span style="font-size:11px;font-weight:600;color:rgba(255,255,255,.72);margin-left:7px;vertical-align:middle">· あなたのAIの日報</span>
|
|
264
|
+
</td></tr>
|
|
265
|
+
</table>
|
|
266
|
+
|
|
267
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
268
|
+
<tr>
|
|
269
|
+
<td width="52" valign="top" style="padding:14px 0 0 18px">
|
|
270
|
+
<div style="width:44px;height:44px;line-height:44px;text-align:center;border-radius:12px;background:${T.av1};background-image:linear-gradient(160deg,${T.av1},${T.av2});color:#fff;font-size:19px;font-weight:800">${esc([...activity.agentName][0] ?? "A")}</div>
|
|
271
|
+
</td>
|
|
272
|
+
<td valign="middle" style="padding:14px 12px 0 12px">
|
|
273
|
+
<div style="font-size:17px;font-weight:800;color:#fff">${esc(activity.displayName)}</div>
|
|
274
|
+
<div style="font-size:10.5px;color:rgba(255,255,255,.72)">${esc(config.ownerName || "あなた")} のAIエージェント</div>
|
|
275
|
+
</td>
|
|
276
|
+
<td align="right" valign="top" style="padding:14px 18px 0 0">${badge}</td>
|
|
277
|
+
</tr>
|
|
278
|
+
</table>
|
|
279
|
+
|
|
280
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
281
|
+
<tr><td style="padding:16px 14px 4px">
|
|
282
|
+
<div style="font-size:10.5px;font-weight:700;color:rgba(255,255,255,.8);padding:0 4px 8px">きのうの働き · ${esc(dateLabel)}</div>
|
|
283
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
284
|
+
<tr>
|
|
285
|
+
${cell(a.activeHours, "h", "実働推定")}
|
|
286
|
+
${cell(a.totalCalls, "", "実行")}
|
|
287
|
+
${cell(a.successRate === null ? "–" : a.successRate, a.successRate === null ? "" : "%", "成功率")}
|
|
288
|
+
${cell(a.failedCalls, "", "失敗", a.failedCalls > 0 ? "#FFD9A8" : "#fff")}
|
|
289
|
+
</tr>
|
|
290
|
+
</table>
|
|
291
|
+
</td></tr>
|
|
292
|
+
</table>
|
|
293
|
+
|
|
294
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
|
295
|
+
<tr><td style="padding:6px 18px 14px;font-size:9.5px;color:rgba(255,255,255,.6);line-height:1.6">
|
|
296
|
+
${esc(config.seatLabel)} · ${esc(modelLine(activity, config))}
|
|
297
|
+
</td></tr>
|
|
298
|
+
</table>
|
|
299
|
+
|
|
300
|
+
</td></tr>
|
|
301
|
+
</table>`;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 下部のアクション。
|
|
306
|
+
*
|
|
307
|
+
* 「編集して記録する」… 送信時に自動で ✓ が付いているが、本文を整えて記録し直せる。
|
|
308
|
+
* 編集して内容が変われば、その版が新しい証明として登録され、パスポートに公開される。
|
|
309
|
+
* 「パスポートを見る」… シェアはパスポート側に集約する。メール直下にシェアボタンは置かない。
|
|
310
|
+
*
|
|
311
|
+
* リンク先が無ければボタンを出さない。押せないボタンを置くより出さないほうがよい。
|
|
312
|
+
*/
|
|
313
|
+
const actions = (activity, config) => {
|
|
314
|
+
const editUrl = config.consoleBase
|
|
315
|
+
? `${config.consoleBase.replace(/\/$/, "")}/reports/edit.html?date=${activity.date}`
|
|
316
|
+
: "";
|
|
317
|
+
const passportUrl = config.passportBase
|
|
318
|
+
? `${config.passportBase.replace(/\/$/, "")}/${activity.slug}`
|
|
319
|
+
: "";
|
|
320
|
+
|
|
321
|
+
// 飛び先が無いときも、ボタンは見た目だけ出す(デザインの確認用)。
|
|
322
|
+
// ただし <a href> にはしない。押しても行けない場所へのリンクは、押した人を混乱させる。
|
|
323
|
+
// 見た目はボタン、実体はただの箱。その旨を1行添えて隠さない。
|
|
324
|
+
const live = Boolean(editUrl || passportUrl);
|
|
325
|
+
|
|
326
|
+
const style = (primary) =>
|
|
327
|
+
`display:inline-block;font-size:13px;font-weight:800;border-radius:999px;padding:11px 22px;text-decoration:none;${
|
|
328
|
+
primary
|
|
329
|
+
? `background:${T.brand1};color:#fff`
|
|
330
|
+
: `background:#fff;color:${T.ink2};border:1.5px solid ${T.line}`
|
|
331
|
+
}`;
|
|
332
|
+
|
|
333
|
+
const btn = (href, label, primary) =>
|
|
334
|
+
href
|
|
335
|
+
? `<a href="${esc(href)}" style="${style(primary)}">${label}</a>`
|
|
336
|
+
: `<span style="${style(primary)};opacity:.75">${label}</span>`;
|
|
337
|
+
|
|
338
|
+
return `
|
|
339
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:20px">
|
|
340
|
+
<tr><td align="center">
|
|
341
|
+
${btn(editUrl, "✏️ 編集して記録する", true)}
|
|
342
|
+
|
|
343
|
+
${btn(passportUrl, "🪪 パスポートを見る", false)}
|
|
344
|
+
</td></tr>
|
|
345
|
+
<tr><td align="center" style="padding-top:10px;font-size:10.5px;color:${T.ink2};line-height:1.7">
|
|
346
|
+
この日報は自動でログ照合・登録済みです(✓)。作業記録なので、必要なら本文を整えて記録できます。<br>
|
|
347
|
+
記録すると、その版が実績としてパスポートに公開されます。
|
|
348
|
+
${
|
|
349
|
+
live
|
|
350
|
+
? ""
|
|
351
|
+
: `<br><span style="color:${T.gold}">※ 上の2つはまだ押せません(コンソールが未公開のため)。公開後に有効になります。</span>`
|
|
352
|
+
}
|
|
353
|
+
</td></tr>
|
|
354
|
+
</table>`;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const ANNOT =
|
|
358
|
+
"※ この日報は手元で生成し、実行ログから集計しています(✓=ログと一致していること。判断の当否・幻覚がないこと・安全であることは意味しません)。実行ログ本体はこの端末から出ていません。";
|
|
359
|
+
|
|
360
|
+
// ── 本体 ────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
export const renderHtml = ({ activity, narrative, proof, config }) => {
|
|
363
|
+
const a = activity.aggregates;
|
|
364
|
+
const d = jpDate(activity.date);
|
|
365
|
+
const today = jpDate(
|
|
366
|
+
new Date(Date.now() + config.tzOffsetMin * 60_000).toISOString().slice(0, 10),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
return `<!DOCTYPE html>
|
|
370
|
+
<html lang="ja">
|
|
371
|
+
<head>
|
|
372
|
+
<meta charset="UTF-8">
|
|
373
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
374
|
+
<meta name="color-scheme" content="light">
|
|
375
|
+
<title>AI業務日報 — ${esc(activity.displayName)} · ${d.short}</title>
|
|
376
|
+
</head>
|
|
377
|
+
<body style="margin:0;padding:0;background:${T.bg};font-family:${T.body};color:${T.ink};font-size:14.5px;line-height:1.7">
|
|
378
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:${T.bg}">
|
|
379
|
+
<tr><td align="center" style="padding:0 12px 40px">
|
|
380
|
+
<table role="presentation" width="520" cellpadding="0" cellspacing="0" border="0" style="width:520px;max-width:100%">
|
|
381
|
+
|
|
382
|
+
<tr><td align="center" style="padding:24px 0 10px">
|
|
383
|
+
<div style="font-size:12px;font-weight:600;color:${T.ink2};letter-spacing:.06em">${today.full} · ${d.short}分</div>
|
|
384
|
+
<div style="font-size:21px;font-weight:800;color:${T.ink};margin-top:2px">AI業務日報</div>
|
|
385
|
+
<div style="font-size:12.5px;color:${T.ink2};margin-top:8px;line-height:1.7">${esc(greeting(config.tzOffsetMin))}</div>
|
|
386
|
+
</td></tr>
|
|
387
|
+
|
|
388
|
+
<tr><td>${dailyHero({ activity, proof, config, dateLabel: `${d.short}(${["日","月","火","水","木","金","土"][new Date(`${activity.date}T00:00:00Z`).getUTCDay()]})` })}</td></tr>
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
<tr><td style="padding-bottom:4px">
|
|
393
|
+
<div style="font-size:10.5px;color:${T.ink2};line-height:1.65">${ANNOT}</div>
|
|
394
|
+
</td></tr>
|
|
395
|
+
|
|
396
|
+
<tr><td style="padding-top:18px">
|
|
397
|
+
<div style="font-size:11px;font-weight:800;color:${T.ink};margin-bottom:9px">本日の業務</div>
|
|
398
|
+
${workBlocks(narrative)}
|
|
399
|
+
</td></tr>
|
|
400
|
+
|
|
401
|
+
<tr><td style="padding-top:8px">
|
|
402
|
+
<div style="font-size:11px;font-weight:800;color:${T.ink};margin-bottom:9px">進捗と申し送り</div>
|
|
403
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate">
|
|
404
|
+
<tr><td style="background:${T.bg};border:1px solid ${T.line};border-radius:14px;padding:14px 16px">
|
|
405
|
+
<div style="font-size:13.5px;font-weight:800;color:${T.ink};margin-bottom:4px">${withRedactions(narrative.handoff?.title ?? "明日にまわすもの")}</div>
|
|
406
|
+
<div style="font-size:13px;color:${T.ink};line-height:1.85">${withRedactions(narrative.handoff?.text ?? "特にありません。")}</div>
|
|
407
|
+
</td></tr>
|
|
408
|
+
</table>
|
|
409
|
+
</td></tr>
|
|
410
|
+
|
|
411
|
+
<tr><td>${insights(narrative)}</td></tr>
|
|
412
|
+
|
|
413
|
+
<tr><td style="padding-top:18px">
|
|
414
|
+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:separate">
|
|
415
|
+
<tr><td style="background:#F6F7FB;border:1px solid ${T.line};border-radius:14px;padding:14px 16px">
|
|
416
|
+
<div style="margin-bottom:6px"><span style="display:inline-block;font-size:9.5px;font-weight:800;background:${T.ink2};color:#fff;padding:2px 10px;border-radius:999px">${esc(activity.agentName)} のひとこと(✓なし)</span></div>
|
|
417
|
+
<div style="font-size:13.5px;color:${T.ink};line-height:1.85">${withRedactions(narrative.selfNote ?? "")}</div>
|
|
418
|
+
</td></tr>
|
|
419
|
+
</table>
|
|
420
|
+
</td></tr>
|
|
421
|
+
|
|
422
|
+
<tr><td>${actions(activity, config)}</td></tr>
|
|
423
|
+
|
|
424
|
+
<tr><td align="center" style="padding:20px 0 0;font-size:11px;color:${T.ink2};line-height:1.9">
|
|
425
|
+
あなたのAIの日報 →
|
|
426
|
+
${
|
|
427
|
+
config.consoleBase
|
|
428
|
+
? `<a href="${esc(config.consoleBase)}" style="color:${T.brand1};font-weight:700;text-decoration:none">${esc(config.consoleBase.replace(/^https?:\/\//, "").replace(/\/$/, ""))}</a>`
|
|
429
|
+
: `<span style="font-weight:700">clubs.place</span>`
|
|
430
|
+
}
|
|
431
|
+
<br>
|
|
432
|
+
通知設定: 日次日報(毎朝)/気づき・提案(届いた日だけ)
|
|
433
|
+
${
|
|
434
|
+
config.consoleBase
|
|
435
|
+
? ` <a href="${esc(config.consoleBase.replace(/\/$/, ""))}/settings/" style="color:${T.brand1};font-weight:700">変更する</a>`
|
|
436
|
+
: ""
|
|
437
|
+
}
|
|
438
|
+
</td></tr>
|
|
439
|
+
|
|
440
|
+
<tr><td align="center" style="padding:18px 0 8px">
|
|
441
|
+
<div style="margin-bottom:8px">${footerProofBadge(proof)}</div>
|
|
442
|
+
<div style="font-family:${T.mono};font-size:10px;color:${T.ink2};word-break:break-all;margin-bottom:8px">docHash: ${esc(proof.docHash)}</div>
|
|
443
|
+
<div style="font-size:11px;color:${T.ink2};line-height:1.7">
|
|
444
|
+
${
|
|
445
|
+
proof.status === "verified"
|
|
446
|
+
? "本文と集計値のハッシュを Lemma に登録しています。改ざんの有無を第三者が確認できます。"
|
|
447
|
+
: `証明基盤への登録はまだです(${esc(proof.reason ?? "-")})。ハッシュ自体は確定しています。`
|
|
448
|
+
}<br>
|
|
449
|
+
この日報は手元(${esc(hostname())})で生成しています。実行ログ本体は送信していません。<br>
|
|
450
|
+
収集元: ${esc(activity.origin)} / 生成: ${esc(narrative.generator ?? "-")} / 対象: ${esc(activity.date)}
|
|
451
|
+
</div>
|
|
452
|
+
</td></tr>
|
|
453
|
+
|
|
454
|
+
</table>
|
|
455
|
+
</td></tr>
|
|
456
|
+
</table>
|
|
457
|
+
</body>
|
|
458
|
+
</html>`;
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
export const buildSubject = ({ activity, proof }) => {
|
|
462
|
+
const d = jpDate(activity.date);
|
|
463
|
+
// Phase 1 は ✓ を付けない。「✓照合予定」は ✓ と紛らわしく、
|
|
464
|
+
// 「✓=ログと一致」の規律を崩すため(コア原則)。
|
|
465
|
+
const mark = proof.status === "verified" ? "✓ログ照合済み" : "照合予定";
|
|
466
|
+
return `[CLUBS] ${d.short}の業務日報 — ${activity.displayName} ${mark}`;
|
|
467
|
+
};
|
package/src/send.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// clubs-nippo — AI業務日報 (CLUBS)
|
|
2
|
+
// Copyright (c) 2026 FRAME00 Inc.
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// https://github.com/frame00/clubs-nippo
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 送信するのは「本文(マスク済)+集計数字+(あれば)署名」だけ。
|
|
8
|
+
* activity.events / activity.prompts(生ログ由来)はここに渡ってこない。
|
|
9
|
+
*/
|
|
10
|
+
export const sendMail = async ({ subject, html, config }) => {
|
|
11
|
+
const { provider, apiKey, from, to } = config.email;
|
|
12
|
+
|
|
13
|
+
if (!apiKey) throw new Error("EMAIL_API_KEY が未設定です。");
|
|
14
|
+
if (!from) throw new Error("EMAIL_FROM が未設定です。");
|
|
15
|
+
if (to.length === 0) throw new Error("EMAIL_TO が未設定です。");
|
|
16
|
+
if (provider !== "resend") {
|
|
17
|
+
throw new Error(`未対応のプロバイダです: ${provider}(現在は resend のみ)`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const res = await fetch("https://api.resend.com/emails", {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${apiKey}`,
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({ from, to, subject, html }),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const json = await res.json().catch(() => ({}));
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Resend ${res.status}: ${json.message ?? json.name ?? "送信に失敗しました"}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return { id: json.id, to };
|
|
36
|
+
};
|
package/src/settings.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// clubs-nippo — AI業務日報 (CLUBS)
|
|
2
|
+
// Copyright (c) 2026 FRAME00 Inc.
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// https://github.com/frame00/clubs-nippo
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { APP_HOME } from "./config.mjs";
|
|
9
|
+
import { collect } from "./collect.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 設定の読み書き。今のところオーナー名だけ。
|
|
13
|
+
*
|
|
14
|
+
* AI名(Claude / Hermes)はここで持たない。収集元が決めるものであって、
|
|
15
|
+
* 設定で書き換えられてはいけない。書き換えられると、他のAIがやった作業を
|
|
16
|
+
* 自分の実績にできてしまい、✓(ログと一致)の意味が崩れる。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ENV = join(APP_HOME, ".env");
|
|
20
|
+
|
|
21
|
+
export const readSettings = (config) => {
|
|
22
|
+
// 収集元から名義を引く。イベントが0件でも名義と origin は取れる。
|
|
23
|
+
const activity = collect({ ...config, date: config.date });
|
|
24
|
+
return {
|
|
25
|
+
ownerName: config.ownerName,
|
|
26
|
+
agentName: activity.agentName, // 変更不可
|
|
27
|
+
displayName: activity.displayName,
|
|
28
|
+
sources: String(config.source).split(",").map((s) => s.trim()),
|
|
29
|
+
origin: activity.origin,
|
|
30
|
+
|
|
31
|
+
// 通知は画面から変えられない。cron(launchd)と .env が実体なので、
|
|
32
|
+
// どこで決まっているかをそのまま出す。画面に切替スイッチだけ置いて実際は
|
|
33
|
+
// 何も変わらない、というのが一番悪い。
|
|
34
|
+
notifications: {
|
|
35
|
+
daily: { label: "日次日報", when: "毎朝 8:00(JST)", to: config.email.to },
|
|
36
|
+
insights: { label: "気づき・提案", when: "届いた日だけ(ログに根拠があるときのみ)" },
|
|
37
|
+
configuredBy: "cron/com.frame00.clubs-nippo.plist と .env(EMAIL_TO)",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** .env の OWNER_NAME だけを書き換える。他の行は触らない。 */
|
|
43
|
+
export const writeOwnerName = (ownerName) => {
|
|
44
|
+
const lines = existsSync(ENV) ? readFileSync(ENV, "utf8").split("\n") : [];
|
|
45
|
+
const idx = lines.findIndex((l) => /^\s*OWNER_NAME\s*=/.test(l));
|
|
46
|
+
const line = `OWNER_NAME=${ownerName}`;
|
|
47
|
+
|
|
48
|
+
const next = idx >= 0 ? lines.with(idx, line) : [...lines, line];
|
|
49
|
+
writeFileSync(ENV, next.join("\n"), "utf8");
|
|
50
|
+
|
|
51
|
+
// 同一プロセス内の後続 loadConfig にも効かせる。
|
|
52
|
+
process.env.OWNER_NAME = ownerName;
|
|
53
|
+
return ownerName;
|
|
54
|
+
};
|