@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/config.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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, existsSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join, dirname } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
export const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 設定(.env)と状態(out/)の置き場所。
|
|
15
|
+
*
|
|
16
|
+
* npx 実行では ROOT が npm キャッシュ(バージョンごとに変わる・使い捨て)に
|
|
17
|
+
* なるため、ROOT に書くと更新のたびに履歴・カードが消える。ユーザー領域の
|
|
18
|
+
* ~/.clubs-nippo に置く。git clone 運用(ROOT に .git がある)は従来どおり
|
|
19
|
+
* ROOT——既存ユーザーの .env と out/ をそのまま生かす。
|
|
20
|
+
*/
|
|
21
|
+
export const APP_HOME =
|
|
22
|
+
process.env.CLUBS_NIPPO_HOME ??
|
|
23
|
+
(existsSync(join(ROOT, ".git")) || existsSync(join(ROOT, ".env"))
|
|
24
|
+
? ROOT
|
|
25
|
+
: join(homedir(), ".clubs-nippo"));
|
|
26
|
+
|
|
27
|
+
/** .env は「まだ環境変数に無いキーだけ」入れる。実環境の変数が常に勝つ。 */
|
|
28
|
+
const loadDotEnv = () => {
|
|
29
|
+
const path = join(APP_HOME, ".env");
|
|
30
|
+
if (!existsSync(path)) return;
|
|
31
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
32
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
33
|
+
if (!m) continue;
|
|
34
|
+
const value = m[2].replace(/^["']|["']$/g, "");
|
|
35
|
+
if (process.env[m[1]] === undefined) process.env[m[1]] = value;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
loadDotEnv();
|
|
39
|
+
|
|
40
|
+
/** JST 基準の「前日」。TZ_OFFSET は分単位のオフセット(既定 +540 = JST)。 */
|
|
41
|
+
const yesterdayLocal = (offsetMin) => {
|
|
42
|
+
const shifted = new Date(Date.now() + offsetMin * 60_000 - 86_400_000);
|
|
43
|
+
return shifted.toISOString().slice(0, 10);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const num = (v, fallback) => (Number.isFinite(Number(v)) ? Number(v) : fallback);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* MASK_PATTERNS は「,」区切りの正規表現ソース。
|
|
50
|
+
* 例: MASK_PATTERNS='株式会社[ぁ-んァ-ヶ一-龠A-Za-z0-9]+,Acme Corp'
|
|
51
|
+
*/
|
|
52
|
+
const parseMaskPatterns = (raw) =>
|
|
53
|
+
(raw ?? "")
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.map((source) => {
|
|
58
|
+
try {
|
|
59
|
+
return new RegExp(source, "gi");
|
|
60
|
+
} catch {
|
|
61
|
+
console.warn(`[config] MASK_PATTERNS の正規表現を無視しました: ${source}`);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.filter(Boolean);
|
|
66
|
+
|
|
67
|
+
export const loadConfig = (argv = {}) => {
|
|
68
|
+
const tzOffsetMin = num(process.env.TZ_OFFSET_MIN, 540);
|
|
69
|
+
return {
|
|
70
|
+
date: argv.date ?? process.env.REPORT_DATE ?? yesterdayLocal(tzOffsetMin),
|
|
71
|
+
tzOffsetMin,
|
|
72
|
+
|
|
73
|
+
// 収集元。カンマ区切りで複数指定できる(名義が同じものに限る)。
|
|
74
|
+
// claude-code = この端末の Claude Code セッション記録
|
|
75
|
+
// cowork = Claude デスクトップアプリ(Cowork)のセッション記録
|
|
76
|
+
// hermes = Hermes Agent のセッション記録(~/.hermes/sessions/)
|
|
77
|
+
// events-file = エージェントが書き出した JSONL(汎用)
|
|
78
|
+
// Claude は Code と Cowork の2面を持つ1体なので、既定で両方を合算する。
|
|
79
|
+
source: argv.source ?? process.env.SOURCE ?? "claude-code,cowork",
|
|
80
|
+
eventsFile: argv.eventsFile ?? process.env.EVENTS_FILE ?? "",
|
|
81
|
+
coworkDir: process.env.COWORK_DIR ?? "",
|
|
82
|
+
|
|
83
|
+
// オーナー名。表示は「mayumi's Claude」のように接頭辞としてのみ効く。
|
|
84
|
+
// 後半(Claude / Hermes)は収集元が決めるので、これで名義を乗っ取ることはできない。
|
|
85
|
+
ownerName: process.env.OWNER_NAME ?? "",
|
|
86
|
+
|
|
87
|
+
// events-file のときだけ有効。claude-code では収集元が名前を決めるので無視される。
|
|
88
|
+
agentName: process.env.AGENT_NAME ?? "Agent",
|
|
89
|
+
agentNameExplicit: process.env.AGENT_NAME !== undefined,
|
|
90
|
+
modelLabel: process.env.MODEL_LABEL ?? "",
|
|
91
|
+
seatLabel: process.env.SEAT_LABEL ?? "席: FRAME00",
|
|
92
|
+
// メールのロゴ。Gmail はインライン SVG も外部 SVG も描画しないので PNG を指す。
|
|
93
|
+
// 公開URLでなければメールから見えないため、未設定ならテキストの "CLUBS" を出す
|
|
94
|
+
// (壊れた画像アイコンを出すより、文字のほうがまし)。
|
|
95
|
+
// 例: https://clubs.place/assets/logo-clubs-white.png
|
|
96
|
+
emailLogoUrl: process.env.EMAIL_LOGO_URL ?? "",
|
|
97
|
+
|
|
98
|
+
// コンソールの公開URLの base(例 https://clubs.place)。
|
|
99
|
+
// 未設定ならメールに「編集して記録する」を出さない(存在しないページに飛ばさない)。
|
|
100
|
+
consoleBase: process.env.CONSOLE_BASE ?? "",
|
|
101
|
+
|
|
102
|
+
// パスポートの公開URLの base(例 https://clubs.place)。
|
|
103
|
+
// 未設定ならメールに「パスポートをシェア」を出さない(存在しないページに飛ばさない)。
|
|
104
|
+
passportBase: process.env.PASSPORT_BASE ?? "",
|
|
105
|
+
|
|
106
|
+
// web コンソール(clubsx)への取込。本文(narrative)は Lemma に無いので、
|
|
107
|
+
// 生成した日報を本文ごとここに push する。設定画面で発行した連携トークンを貼る。
|
|
108
|
+
// 未設定なら取込をスキップ(メール・Lemma 登録は従来どおり動く)。
|
|
109
|
+
ingestUrl: process.env.NIPPO_INGEST_URL ?? "",
|
|
110
|
+
ingestToken: process.env.NIPPO_INGEST_TOKEN ?? "",
|
|
111
|
+
|
|
112
|
+
transcriptDir: process.env.TRANSCRIPT_DIR ?? join(homedir(), ".claude", "projects"),
|
|
113
|
+
idleGapMin: num(process.env.IDLE_GAP_MIN, 15),
|
|
114
|
+
includePrompts: process.env.INCLUDE_PROMPTS !== "0",
|
|
115
|
+
maskPatterns: parseMaskPatterns(process.env.MASK_PATTERNS),
|
|
116
|
+
|
|
117
|
+
// 本文生成の担い手。収集元と同じサービスに揃えると、1つのサブスクだけで
|
|
118
|
+
// 実行ログ→日報まで完結する(claude-code なら claude、codex-cli なら codex)。
|
|
119
|
+
// claude / codex / gemini / copilot … その端末の AI CLI をヘッドレスで叩く
|
|
120
|
+
// openrouter = OpenRouter API
|
|
121
|
+
// openai-compat = OpenAI互換カスタム(Sakana Fugu 等)
|
|
122
|
+
// none = LLM を使わない
|
|
123
|
+
generator: process.env.GENERATOR ?? "claude",
|
|
124
|
+
openrouter: {
|
|
125
|
+
apiKey: process.env.OPENROUTER_API_KEY ?? "",
|
|
126
|
+
model: process.env.OPENROUTER_MODEL ?? "anthropic/claude-sonnet-4.5",
|
|
127
|
+
},
|
|
128
|
+
// OpenAI 互換カスタムエンドポイント。base URL / model は接続設定から来てもよいが
|
|
129
|
+
// API キーは常にローカル .env のみ(非カストディアル)。
|
|
130
|
+
backend: {
|
|
131
|
+
baseUrl: process.env.BACKEND_BASE_URL ?? "",
|
|
132
|
+
apiKey: process.env.BACKEND_API_KEY ?? "",
|
|
133
|
+
model: process.env.BACKEND_MODEL ?? "",
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// カードのしきい値。ハードコードしない(チームごとに違う)。
|
|
137
|
+
// 既定値は docs/card-catalog.md §4。
|
|
138
|
+
cards: {
|
|
139
|
+
bigjobCalls: num(process.env.BIGJOB_CALLS, 40),
|
|
140
|
+
noFailDays: num(process.env.NOFAIL_DAYS, 30),
|
|
141
|
+
precisionDays: num(process.env.PRECISION_DAYS, 90),
|
|
142
|
+
cleanCount: num(process.env.CLEAN_COUNT, 10),
|
|
143
|
+
titleDays: num(process.env.TITLE_DAYS, 10),
|
|
144
|
+
mergePassRate: num(process.env.MERGE_PASS_RATE, 98),
|
|
145
|
+
// カタログ本文に数値がある条件も、同じく設定キーにする。
|
|
146
|
+
comebackEarlyFail: num(process.env.COMEBACK_EARLY_FAIL, 30),
|
|
147
|
+
comebackFinalRate: num(process.env.COMEBACK_FINAL_RATE, 95),
|
|
148
|
+
oneTrickPct: num(process.env.ONE_TRICK_PCT, 80),
|
|
149
|
+
owlAfter: process.env.OWL_AFTER ?? "23:00",
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
email: {
|
|
153
|
+
provider: process.env.EMAIL_PROVIDER ?? "resend",
|
|
154
|
+
apiKey: process.env.EMAIL_API_KEY ?? "",
|
|
155
|
+
from: process.env.EMAIL_FROM ?? "",
|
|
156
|
+
to: (process.env.EMAIL_TO ?? "")
|
|
157
|
+
.split(",")
|
|
158
|
+
.map((s) => s.trim())
|
|
159
|
+
.filter(Boolean),
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
lemma: {
|
|
163
|
+
base: process.env.LEMMA_API_BASE ?? "https://workers.lemma.workers.dev",
|
|
164
|
+
apiKey: process.env.LEMMA_API_KEY ?? "",
|
|
165
|
+
scopeId: process.env.LEMMA_SCOPE_ID ?? "",
|
|
166
|
+
// 検証UIのURLが分かっている場合のみリンク化する。未設定なら docHash のみ表示。
|
|
167
|
+
verifyBase: process.env.LEMMA_VERIFY_BASE ?? "",
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
useLlm: argv.noLlm !== true,
|
|
171
|
+
dryRun: argv.dryRun === true,
|
|
172
|
+
};
|
|
173
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
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 { makeCliGenerator } from "./cli.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* claude -p(Claude Code のヘッドレス実行)で本文を書く。
|
|
10
|
+
* 既存のサブスクリプション内で動くので、別の LLM API キーは要らない。
|
|
11
|
+
* Claude Code が入っていないマシンでは失敗し、呼び出し側が fallback に落とす。
|
|
12
|
+
*/
|
|
13
|
+
export default makeCliGenerator({
|
|
14
|
+
id: "claude",
|
|
15
|
+
bin: "claude",
|
|
16
|
+
binEnv: "CLAUDE_BIN",
|
|
17
|
+
label: "claude-code",
|
|
18
|
+
// 日報の本文書きはログのダイジェストを整形するだけで、MCP サーバーは1つも要らない。
|
|
19
|
+
// 既定では利用者が普段使っている MCP を全部起動してしまい、起動を待つあいだに
|
|
20
|
+
// 実測で 55 秒(137s→82s)を捨てる。接続待ちで固まる MCP があれば丸ごと巻き込まれる。
|
|
21
|
+
// --strict-mcp-config + 空の --mcp-config で、この生成のあいだだけ MCP を切る。
|
|
22
|
+
args: (prompt) => [
|
|
23
|
+
"-p",
|
|
24
|
+
prompt,
|
|
25
|
+
"--output-format",
|
|
26
|
+
"text",
|
|
27
|
+
"--strict-mcp-config",
|
|
28
|
+
"--mcp-config",
|
|
29
|
+
'{"mcpServers":{}}',
|
|
30
|
+
],
|
|
31
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
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 { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { ROOT } from "../config.mjs";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 「その端末に入っている AI CLI をヘッドレスで叩いて本文を書かせる」生成器の共通形。
|
|
14
|
+
*
|
|
15
|
+
* 収集元と生成器を同じサービスで揃えられるようにするためのもの。Codex CLI しか
|
|
16
|
+
* 入れていない端末で claude -p を呼んでも外れるだけなので、ログを出したのと同じ
|
|
17
|
+
* CLI に本文も書かせる(実行ログ→日報を1つのサブスクで完結させる)。
|
|
18
|
+
*
|
|
19
|
+
* どれも「ユーザーが既に契約している CLI」を使うので、別の API キーは要らない。
|
|
20
|
+
* 入っていないマシンでは実行が失敗し、呼び出し側が fallback に落とす。
|
|
21
|
+
*
|
|
22
|
+
* バイナリのパスは <ID>_BIN で上書きできる(cron の細い PATH 対策)。
|
|
23
|
+
*/
|
|
24
|
+
export const makeCliGenerator = ({ id, bin, binEnv, label, args }) => ({
|
|
25
|
+
id,
|
|
26
|
+
label: () => label,
|
|
27
|
+
generate: async (prompt) => {
|
|
28
|
+
const command = process.env[binEnv] || bin;
|
|
29
|
+
// 生成の上限時間。大きい日はプロンプトが数万字になり既定 180s では足りず
|
|
30
|
+
// timeout(SIGTERM)で fallback に落ちることがあるので、少し長めにし env で伸ばせる。
|
|
31
|
+
const timeout = Number(process.env.GENERATOR_TIMEOUT_MS) || 240_000;
|
|
32
|
+
const startedAt = Date.now();
|
|
33
|
+
const pending = execFileAsync(command, args(prompt), {
|
|
34
|
+
cwd: ROOT,
|
|
35
|
+
timeout,
|
|
36
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
37
|
+
});
|
|
38
|
+
// ヘッドレスCLI(claude -p 等)は stdin からの追加入力を待つ。cron / launchd の
|
|
39
|
+
// 非TTY環境では入力も EOF も来ず数秒空回りし、巨大プロンプトと相まって timeout で
|
|
40
|
+
// 殺される("no stdin data received in 3s" 警告の正体)。即 EOF を送って
|
|
41
|
+
// 「< /dev/null」相当にし、待ちをなくす。
|
|
42
|
+
pending.child?.stdin?.end();
|
|
43
|
+
try {
|
|
44
|
+
const { stdout } = await pending;
|
|
45
|
+
return stdout;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new Error(explainFailure(e, { command, timeout, elapsed: Date.now() - startedAt }));
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 失敗の理由を「読んで直せる一行」にする。
|
|
54
|
+
*
|
|
55
|
+
* execFile の既定メッセージは `Command failed: <実行したコマンド全文>` で、ここには
|
|
56
|
+
* 数万字のプロンプトがそのまま入る。それが fallback の理由として下書き JSON・cron ログ・
|
|
57
|
+
* メール本文にまで流れ込み、肝心の「なぜ落ちたか」が埋もれる。プロンプトは捨て、
|
|
58
|
+
* 終わり方(タイムアウト / 異常終了 / 未インストール)と stderr の末尾だけを残す。
|
|
59
|
+
*/
|
|
60
|
+
const explainFailure = (e, { command, timeout, elapsed }) => {
|
|
61
|
+
if (e.code === "ENOENT") return `${command} が見つかりません`;
|
|
62
|
+
|
|
63
|
+
const tail = (e.stderr || e.stdout || "").trim().split("\n").slice(-3).join(" / ").slice(0, 300);
|
|
64
|
+
const suffix = tail ? `: ${tail}` : "";
|
|
65
|
+
const secs = (ms) => `${Math.round(ms / 1000)}秒`;
|
|
66
|
+
|
|
67
|
+
if (e.killed || e.signal === "SIGTERM") {
|
|
68
|
+
// 実時間がタイムアウトを大きく超えていたら、待っていたのではなく途中で止められている。
|
|
69
|
+
// 典型は Mac の二度寝(DarkWake の隙に起動 → 45秒で再スリープ → プロセスもタイマーも凍結)。
|
|
70
|
+
// 電源都合は設定で直せる話なので、単なる「時間切れ」と混ぜずに言い分ける。
|
|
71
|
+
if (elapsed > timeout * 1.5) {
|
|
72
|
+
return (
|
|
73
|
+
`${command} が実行中に凍結しました(${secs(timeout)}の予定が実時間 ${secs(elapsed)})。` +
|
|
74
|
+
`生成の途中で Mac がスリープした可能性があります${suffix}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return `${command} が ${secs(timeout)} で終わりませんでした(GENERATOR_TIMEOUT_MS で延ばせます)${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return `${command} が異常終了しました(exit ${e.code ?? "?"})${suffix}`;
|
|
81
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
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 { makeCliGenerator } from "./cli.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* codex exec(Codex CLI の非対話実行)で本文を書く。
|
|
10
|
+
* 収集元 codex-cli とセットで使う想定=Codex のサブスクだけで日報まで完結する。
|
|
11
|
+
*
|
|
12
|
+
* 実機検証: 2026-07-24 Hermes サーバーで確認(codex-cli v0.145.0)。
|
|
13
|
+
* --skip-git-repo-check … cron の cwd が trusted directory 外でも通す
|
|
14
|
+
* --dangerously-bypass-approvals-and-sandbox … 非TTYで承認待ちにならない
|
|
15
|
+
* 前提: OPENAI_API_KEY が環境変数に必要(Codex CLI が読み取る)
|
|
16
|
+
*/
|
|
17
|
+
export default makeCliGenerator({
|
|
18
|
+
id: "codex",
|
|
19
|
+
bin: "codex",
|
|
20
|
+
binEnv: "CODEX_BIN",
|
|
21
|
+
label: "codex-cli",
|
|
22
|
+
args: (prompt) => [
|
|
23
|
+
"exec",
|
|
24
|
+
"--skip-git-repo-check",
|
|
25
|
+
"--dangerously-bypass-approvals-and-sandbox",
|
|
26
|
+
prompt,
|
|
27
|
+
],
|
|
28
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
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 { makeCliGenerator } from "./cli.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* copilot -p(GitHub Copilot CLI の非対話実行)で本文を書く。
|
|
10
|
+
* 収集元 copilot-cli とセットで使う想定。
|
|
11
|
+
*
|
|
12
|
+
* ⚠ 実機未検証(2026-07-22 時点)。Copilot CLI 自体が実験的で、フラグの変更が
|
|
13
|
+
* 起きやすい。実ログ検証で確定させる。
|
|
14
|
+
*/
|
|
15
|
+
export default makeCliGenerator({
|
|
16
|
+
id: "copilot",
|
|
17
|
+
bin: "copilot",
|
|
18
|
+
binEnv: "COPILOT_BIN",
|
|
19
|
+
label: "copilot-cli",
|
|
20
|
+
args: (prompt) => ["-p", prompt],
|
|
21
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
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 { makeCliGenerator } from "./cli.mjs";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* gemini -p(Gemini CLI の非対話実行)で本文を書く。
|
|
10
|
+
* 収集元 gemini-cli とセットで使う想定。
|
|
11
|
+
*
|
|
12
|
+
* ⚠ 実機未検証(2026-07-22 時点)。フラグ・出力形式は実ログ検証で確定させる。
|
|
13
|
+
*/
|
|
14
|
+
export default makeCliGenerator({
|
|
15
|
+
id: "gemini",
|
|
16
|
+
bin: "gemini",
|
|
17
|
+
binEnv: "GEMINI_BIN",
|
|
18
|
+
label: "gemini-cli",
|
|
19
|
+
args: (prompt) => ["-p", prompt],
|
|
20
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
* OpenAI 互換のカスタムエンドポイントで本文を書く(第3の生成バックエンド)。
|
|
8
|
+
*
|
|
9
|
+
* Sakana Fugu 等の国産AIを含む、OpenAI Chat Completions 互換の任意サーバに対応。
|
|
10
|
+
* 「本文生成はユーザー側のLLM」という非カストディアル原則は保たれる——
|
|
11
|
+
* base URL・モデルは CLUBS の接続設定(collector-config)から来てもよいが、
|
|
12
|
+
* **API キーは常にローカル .env のみ**(CLUBS には保存も送信もされない)。
|
|
13
|
+
*
|
|
14
|
+
* 必要な設定:
|
|
15
|
+
* GENERATOR=openai-compat
|
|
16
|
+
* BACKEND_BASE_URL=https://api.example.com/v1 (/chat/completions は自動補完)
|
|
17
|
+
* BACKEND_API_KEY=sk-... (.env のみ・画面には入力しない)
|
|
18
|
+
* BACKEND_MODEL=your-model-id
|
|
19
|
+
*
|
|
20
|
+
* 注意: ここに渡るのはダイジェスト(ツール名・時刻・成否・マスク済みの短い要約)だけ。
|
|
21
|
+
* それでも外部APIに送るので、指示文も渡したくない場合は INCLUDE_PROMPTS=0。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** base URL から chat/completions のエンドポイントを組む(末尾スラッシュ・重複を吸収)。 */
|
|
25
|
+
const endpointOf = (baseUrl) => {
|
|
26
|
+
const b = String(baseUrl || "").trim().replace(/\/+$/, "");
|
|
27
|
+
if (!b) return "";
|
|
28
|
+
if (/\/chat\/completions$/.test(b)) return b;
|
|
29
|
+
return `${b}/chat/completions`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
id: "openai-compat",
|
|
34
|
+
|
|
35
|
+
label: (config) => `openai-compat:${config.backend.model || "?"}`,
|
|
36
|
+
|
|
37
|
+
generate: async (prompt, config) => {
|
|
38
|
+
const { baseUrl, apiKey, model } = config.backend;
|
|
39
|
+
const endpoint = endpointOf(baseUrl);
|
|
40
|
+
if (!endpoint) throw new Error("BACKEND_BASE_URL が未設定です。");
|
|
41
|
+
if (!apiKey) throw new Error("BACKEND_API_KEY が未設定です(.env にのみ置きます)。");
|
|
42
|
+
if (!model) throw new Error("BACKEND_MODEL が未設定です。");
|
|
43
|
+
|
|
44
|
+
const ctrl = new AbortController();
|
|
45
|
+
const timer = setTimeout(() => ctrl.abort(), 180_000);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(endpoint, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
signal: ctrl.signal,
|
|
51
|
+
headers: {
|
|
52
|
+
Authorization: `Bearer ${apiKey}`,
|
|
53
|
+
"Content-Type": "application/json",
|
|
54
|
+
"X-Title": "clubs-nippo",
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
model,
|
|
58
|
+
messages: [{ role: "user", content: prompt }],
|
|
59
|
+
// 日報は事実の要約。creative である必要はない。
|
|
60
|
+
temperature: 0.3,
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
const text = await res.text().catch(() => "");
|
|
66
|
+
throw new Error(`OpenAI互換 ${res.status}: ${text.slice(0, 160)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const json = await res.json();
|
|
70
|
+
const content = json.choices?.[0]?.message?.content;
|
|
71
|
+
if (typeof content !== "string") {
|
|
72
|
+
throw new Error("OpenAI互換エンドポイントの応答に本文がありません。");
|
|
73
|
+
}
|
|
74
|
+
return content;
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
* OpenRouter で本文を書く。
|
|
8
|
+
*
|
|
9
|
+
* モデルを切り替えて使うエージェント(CTO の Hermes など)向け。オーナーが既に
|
|
10
|
+
* 課金している LLM をそのまま使うので、「本文生成はユーザー側のLLM」という原則は保たれる。
|
|
11
|
+
*
|
|
12
|
+
* 必要な設定:
|
|
13
|
+
* GENERATOR=openrouter
|
|
14
|
+
* OPENROUTER_API_KEY=sk-or-...
|
|
15
|
+
* OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 (既定。どのモデルでもよい)
|
|
16
|
+
*
|
|
17
|
+
* 注意: ここに渡るのはダイジェスト(ツール名・時刻・成否・マスク済みの短い要約)だけで、
|
|
18
|
+
* コマンド全文やファイル内容は含まれない。それでも外部APIに送ることに変わりはないので、
|
|
19
|
+
* 指示文も渡したくない場合は INCLUDE_PROMPTS=0 にする。
|
|
20
|
+
*/
|
|
21
|
+
export default {
|
|
22
|
+
id: "openrouter",
|
|
23
|
+
|
|
24
|
+
label: (config) => `openrouter:${config.openrouter.model}`,
|
|
25
|
+
|
|
26
|
+
generate: async (prompt, config) => {
|
|
27
|
+
const { apiKey, model } = config.openrouter;
|
|
28
|
+
if (!apiKey) throw new Error("OPENROUTER_API_KEY が未設定です。");
|
|
29
|
+
|
|
30
|
+
const ctrl = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => ctrl.abort(), 180_000);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
signal: ctrl.signal,
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${apiKey}`,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"X-Title": "clubs-nippo",
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model,
|
|
44
|
+
messages: [{ role: "user", content: prompt }],
|
|
45
|
+
// 日報は事実の要約。creative である必要はない。
|
|
46
|
+
temperature: 0.3,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
const text = await res.text().catch(() => "");
|
|
52
|
+
throw new Error(`OpenRouter ${res.status}: ${text.slice(0, 160)}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const json = await res.json();
|
|
56
|
+
const content = json.choices?.[0]?.message?.content;
|
|
57
|
+
if (typeof content !== "string") {
|
|
58
|
+
throw new Error("OpenRouter の応答に本文がありません。");
|
|
59
|
+
}
|
|
60
|
+
return content;
|
|
61
|
+
} finally {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
package/src/history.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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, mkdirSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { APP_HOME } from "./config.mjs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 日次集計の履歴(out/history.jsonl・1日1行)。
|
|
12
|
+
*
|
|
13
|
+
* approved.json は「その日1日」の確定内容しか持たない。
|
|
14
|
+
* 「失敗ゼロ30日連続」「差し戻し率<1%を90日」のような条件は**系列**が要るので、
|
|
15
|
+
* 日次の aggregates をここに積む。DB は使わない(依存を増やさない)。
|
|
16
|
+
*
|
|
17
|
+
* 冪等: 同じ date の行が既にあれば上書きする。cron が二度走っても重複しない。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const PATH = () => join(APP_HOME, "out", "history.jsonl");
|
|
21
|
+
|
|
22
|
+
export const readHistory = () => {
|
|
23
|
+
if (!existsSync(PATH())) return [];
|
|
24
|
+
return readFileSync(PATH(), "utf8")
|
|
25
|
+
.split("\n")
|
|
26
|
+
.filter((l) => l.trim())
|
|
27
|
+
.map((l) => {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(l);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const appendHistory = (activity) => {
|
|
39
|
+
mkdirSync(join(APP_HOME, "out"), { recursive: true });
|
|
40
|
+
const a = activity.aggregates;
|
|
41
|
+
|
|
42
|
+
const row = {
|
|
43
|
+
date: activity.date,
|
|
44
|
+
agent: activity.agentName,
|
|
45
|
+
totalCalls: a.totalCalls,
|
|
46
|
+
failedCalls: a.failedCalls,
|
|
47
|
+
successRate: a.successRate,
|
|
48
|
+
activeHours: a.activeHours,
|
|
49
|
+
toolCounts: a.toolCounts,
|
|
50
|
+
toolCountsAm: a.toolCountsAm,
|
|
51
|
+
toolCountsPm: a.toolCountsPm,
|
|
52
|
+
eventsByHour: a.eventsByHour,
|
|
53
|
+
earlyFailRate: a.earlyFailRate,
|
|
54
|
+
firstAt: a.firstAt,
|
|
55
|
+
lastAt: a.lastAt,
|
|
56
|
+
// firstTrySuccessRate(一発通過率)と reworkRate(差し戻し率)は今の収集元から出ない。
|
|
57
|
+
// 無い値を作らない。これらを使うカードは「データ未取得」として評価をスキップする。
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const rows = readHistory().filter((r) => r.date !== row.date);
|
|
61
|
+
rows.push(row);
|
|
62
|
+
rows.sort((a2, b2) => a2.date.localeCompare(b2.date));
|
|
63
|
+
|
|
64
|
+
writeFileSync(PATH(), rows.map((r) => JSON.stringify(r)).join("\n") + "\n", "utf8");
|
|
65
|
+
return row;
|
|
66
|
+
};
|
package/src/ingest.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
* 生成した日報を web コンソール(clubsx)へ取り込ませる。
|
|
8
|
+
*
|
|
9
|
+
* 本文(workBlocks / handoff / insights / selfNote)は Lemma には登録されない
|
|
10
|
+
* (Lemma は docHash+集計値のみ)。だから web コンソールで本文まで見せるには、
|
|
11
|
+
* ここで **本文ごと** clubsx に push する必要がある。
|
|
12
|
+
*
|
|
13
|
+
* 認証は clubsx の設定画面で発行した「連携トークン」(Bearer)。Lemma API キーとは
|
|
14
|
+
* 別レイヤーで、本文配送の認可だけを担う。収集が既に済ませた Lemma 登録結果
|
|
15
|
+
* (docHash+proof)も一緒に送るので、clubsx は再登録しない(V の二重計上を避ける)。
|
|
16
|
+
*
|
|
17
|
+
* URL/トークン未設定・到達不可・エラーのいずれでも throw せず {ok:false, reason} を
|
|
18
|
+
* 返す(メール送信や Lemma 登録を巻き込まない)。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const withTimeout = async (fn, ms) => {
|
|
22
|
+
const ctrl = new AbortController();
|
|
23
|
+
const timer = setTimeout(() => ctrl.abort(), ms);
|
|
24
|
+
try {
|
|
25
|
+
return await fn(ctrl.signal);
|
|
26
|
+
} finally {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const pushToClubs = async (activity, narrative, proof, config) => {
|
|
32
|
+
const url = config.ingestUrl;
|
|
33
|
+
const token = config.ingestToken;
|
|
34
|
+
if (!url || !token) {
|
|
35
|
+
return { ok: false, reason: "NIPPO_INGEST_URL / NIPPO_INGEST_TOKEN 未設定" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 境界の強制: 端末を出てよいのは集計値+マスク済み本文+署名だけ。
|
|
39
|
+
// activity には events/prompts など生ログ由来のフィールドがぶら下がるため、
|
|
40
|
+
// 丸ごと送らずホワイトリストで整形する(docHash の入力は date+aggregates+
|
|
41
|
+
// 本文なので影響しない)。
|
|
42
|
+
const activityOut = {
|
|
43
|
+
date: activity.date,
|
|
44
|
+
agentName: activity.agentName,
|
|
45
|
+
displayName: activity.displayName,
|
|
46
|
+
source: activity.source,
|
|
47
|
+
origin: activity.origin,
|
|
48
|
+
aggregates: activity.aggregates,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const res = await withTimeout(
|
|
53
|
+
(signal) =>
|
|
54
|
+
fetch(url, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
signal,
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "application/json",
|
|
59
|
+
Authorization: `Bearer ${token}`,
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({ activity: activityOut, narrative, proof }),
|
|
62
|
+
}),
|
|
63
|
+
15_000,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (!res.ok) {
|
|
67
|
+
const text = await res.text().catch(() => "");
|
|
68
|
+
return { ok: false, reason: `ingest ${res.status}: ${text.slice(0, 160)}` };
|
|
69
|
+
}
|
|
70
|
+
/* サーバは「稼働のなかった日」を日報にしない(土日など)。取込は成功だが
|
|
71
|
+
日報は作られず、メールも出ない。それを ok と同じ顔で伝えると、届いて
|
|
72
|
+
いないものを「送りました」と言うことになるので、区別して返す。 */
|
|
73
|
+
const body = await res.json().catch(() => ({}));
|
|
74
|
+
return { ok: true, quiet: body?.quiet === true };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { ok: false, reason: `clubsx 取込に失敗: ${e?.message ?? "不明なエラー"}` };
|
|
77
|
+
}
|
|
78
|
+
};
|