@clubs.place/cli 0.0.0-stage → 0.1.18
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 +44 -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
|
@@ -0,0 +1,120 @@
|
|
|
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 } from "node:fs";
|
|
7
|
+
import { maskText } from "../mask.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 汎用の収集元。Claude Code 以外のエージェント(Telegram 常駐の Hermes など)は、
|
|
11
|
+
* 自分の実行記録をこの形の JSONL に吐き出せば、そのまま日報にできる。
|
|
12
|
+
*
|
|
13
|
+
* 1行1イベント。必須は ts と tool と ok の3つだけ。
|
|
14
|
+
*
|
|
15
|
+
* {"ts":"2026-07-13T09:12:00+09:00","tool":"telegram.reply","ok":true,
|
|
16
|
+
* "summary":"問い合わせに返信","project":"support"}
|
|
17
|
+
*
|
|
18
|
+
* ts … ISO8601。タイムゾーン付きが望ましい(無ければ UTC 扱い)
|
|
19
|
+
* tool … 実行したものの名前。日報では「ツール」として集計・表示する
|
|
20
|
+
* ok … 成否。true/false。成功率はこれだけから出す
|
|
21
|
+
* summary … 任意。1行の要約。マスク対象は伏せてから書いても、MASK_PATTERNS に任せてもよい
|
|
22
|
+
* project … 任意。案件名。日報のブロック分けに使う
|
|
23
|
+
* model … 任意。その実行に使ったモデル(例 "anthropic/claude-sonnet-4.5")。
|
|
24
|
+
* OpenRouter 等でモデルを切り替えるエージェント向け。
|
|
25
|
+
* エージェント=身元、モデル=取り替え可能な部品。モデルは身元ではないので、
|
|
26
|
+
* 名義には一切影響しない。日報にはモデルの内訳として出る。
|
|
27
|
+
* kind … 任意。"instruction" ならオーナーからの指示文として扱う(tool/ok は不要)
|
|
28
|
+
*
|
|
29
|
+
* ok を記録していないエージェントは、成功率を出せない。その場合 ok を省略すれば
|
|
30
|
+
* 成功率は「-」になり、✓ の対象からも外れる(無いものを在ることにしない)。
|
|
31
|
+
*/
|
|
32
|
+
const parseLine = (line, index) => {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(line);
|
|
35
|
+
} catch {
|
|
36
|
+
console.warn(`[events-file] ${index + 1} 行目を JSON として読めませんでした。無視します。`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const localParts = (iso, tzOffsetMin) => {
|
|
42
|
+
const t = Date.parse(iso);
|
|
43
|
+
if (Number.isNaN(t)) return null;
|
|
44
|
+
const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
|
|
45
|
+
return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const collectEventsFile = (config) => {
|
|
49
|
+
const { date, tzOffsetMin, maskPatterns, includePrompts, eventsFile } = config;
|
|
50
|
+
|
|
51
|
+
if (!eventsFile) {
|
|
52
|
+
throw new Error("SOURCE=events-file には EVENTS_FILE(JSONL のパス)が必要です。");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let lines;
|
|
56
|
+
try {
|
|
57
|
+
lines = readFileSync(eventsFile, "utf8").split("\n");
|
|
58
|
+
} catch (e) {
|
|
59
|
+
throw new Error(`EVENTS_FILE を読めません: ${eventsFile} (${e.message})`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const events = [];
|
|
63
|
+
const prompts = [];
|
|
64
|
+
let maskedCount = 0;
|
|
65
|
+
let unknownOk = 0;
|
|
66
|
+
|
|
67
|
+
lines.forEach((line, i) => {
|
|
68
|
+
if (!line.trim()) return;
|
|
69
|
+
const raw = parseLine(line, i);
|
|
70
|
+
if (!raw) return;
|
|
71
|
+
|
|
72
|
+
const parts = localParts(raw.ts, tzOffsetMin);
|
|
73
|
+
if (!parts || parts.date !== date) return;
|
|
74
|
+
|
|
75
|
+
if (raw.kind === "instruction") {
|
|
76
|
+
if (!includePrompts) return;
|
|
77
|
+
const masked = maskText(String(raw.summary ?? raw.text ?? ""), maskPatterns);
|
|
78
|
+
maskedCount += masked.count;
|
|
79
|
+
const trimmed = masked.text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
80
|
+
if (trimmed) prompts.push({ time: parts.time, text: trimmed });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!raw.tool) return;
|
|
85
|
+
|
|
86
|
+
const masked = maskText(String(raw.summary ?? ""), maskPatterns);
|
|
87
|
+
maskedCount += masked.count;
|
|
88
|
+
if (typeof raw.ok !== "boolean") unknownOk += 1;
|
|
89
|
+
|
|
90
|
+
const project = String(raw.project ?? "").trim() || "(案件未設定)";
|
|
91
|
+
|
|
92
|
+
events.push({
|
|
93
|
+
id: `${i}`,
|
|
94
|
+
name: String(raw.tool),
|
|
95
|
+
summary: masked.text.slice(0, 120),
|
|
96
|
+
time: parts.time,
|
|
97
|
+
ms: parts.ms,
|
|
98
|
+
project,
|
|
99
|
+
ok: typeof raw.ok === "boolean" ? raw.ok : null,
|
|
100
|
+
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : null,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (unknownOk > 0) {
|
|
105
|
+
console.warn(
|
|
106
|
+
`[events-file] ok を持たないイベントが ${unknownOk} 件あります。成功率の分母から外しました。`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { events, prompts, sessions: new Set(["events-file"]), maskedCount };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export default {
|
|
114
|
+
id: "events-file",
|
|
115
|
+
// 名乗る名前はこのソースでは外から与える(AGENT_NAME)。
|
|
116
|
+
// ただし origin に「どのファイルから作ったか」が必ず出るので、出所は隠せない。
|
|
117
|
+
agentName: null,
|
|
118
|
+
origin: "エージェントが書き出した実行記録(JSONL)",
|
|
119
|
+
collect: collectEventsFile,
|
|
120
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
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 { readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { maskText } from "../mask.mjs";
|
|
10
|
+
import { normalizeToolName } from "../tool-name.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Google Gemini CLI を収集元とする。
|
|
14
|
+
*
|
|
15
|
+
* Gemini CLI が既定で残すローカル痕跡は薄い:
|
|
16
|
+
* - ~/.gemini/tmp/<hash>/logs.json … オーナーの指示(user メッセージ)のみ
|
|
17
|
+
* - ツール実行イベントは telemetry を有効にした場合のみファイルに出る
|
|
18
|
+
* (gemini --telemetry --telemetry-target=local --telemetry-outfile=…)
|
|
19
|
+
*
|
|
20
|
+
* なので、指示は logs.json から、ツール実行は GEMINI_TELEMETRY_FILE
|
|
21
|
+
* (OTLP JSON lines)から拾う。**telemetry が無ければツール実行は出さない**
|
|
22
|
+
* (シグナルが無いものを在ることにしない=成功率も出ない)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const GEMINI_DIR = () => process.env.GEMINI_DIR ?? join(homedir(), ".gemini");
|
|
26
|
+
|
|
27
|
+
const localParts = (isoOrMs, tzOffsetMin) => {
|
|
28
|
+
const t = typeof isoOrMs === "number" ? isoOrMs : Date.parse(isoOrMs);
|
|
29
|
+
if (Number.isNaN(t)) return null;
|
|
30
|
+
const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
|
|
31
|
+
return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const collectGemini = (config) => {
|
|
35
|
+
const { date, tzOffsetMin, maskPatterns, includePrompts } = config;
|
|
36
|
+
const events = [];
|
|
37
|
+
const prompts = [];
|
|
38
|
+
const sessions = new Set();
|
|
39
|
+
let maskedCount = 0;
|
|
40
|
+
|
|
41
|
+
// ── 指示(logs.json・user メッセージ)─────────────────────
|
|
42
|
+
if (includePrompts) {
|
|
43
|
+
const tmp = join(GEMINI_DIR(), "tmp");
|
|
44
|
+
let dirs = [];
|
|
45
|
+
try {
|
|
46
|
+
dirs = readdirSync(tmp, { withFileTypes: true })
|
|
47
|
+
.filter((e) => e.isDirectory())
|
|
48
|
+
.map((e) => join(tmp, e.name));
|
|
49
|
+
} catch {
|
|
50
|
+
/* Gemini CLI 未使用 */
|
|
51
|
+
}
|
|
52
|
+
for (const dir of dirs) {
|
|
53
|
+
let rows;
|
|
54
|
+
try {
|
|
55
|
+
rows = JSON.parse(readFileSync(join(dir, "logs.json"), "utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(rows)) continue;
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
if (row?.type !== "user" || typeof row?.message !== "string") continue;
|
|
62
|
+
const parts = localParts(row.timestamp, tzOffsetMin);
|
|
63
|
+
if (!parts || parts.date !== date) continue;
|
|
64
|
+
const masked = maskText(row.message, maskPatterns);
|
|
65
|
+
maskedCount += masked.count;
|
|
66
|
+
const trimmed = masked.text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
67
|
+
if (trimmed) prompts.push({ time: parts.time, text: trimmed });
|
|
68
|
+
if (row.sessionId) sessions.add(String(row.sessionId));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── ツール実行(telemetry ファイル・任意)───────────────────
|
|
74
|
+
// gemini --telemetry --telemetry-target=local --telemetry-outfile=… の出力は
|
|
75
|
+
// 「整形(インデント2)された JSON レコードの連なり」=1行1JSON ではない。
|
|
76
|
+
// 行頭の `{` を区切りにレコード単位へ割ってからパースする。
|
|
77
|
+
const telemetryFile = process.env.GEMINI_TELEMETRY_FILE;
|
|
78
|
+
if (telemetryFile) {
|
|
79
|
+
let text = "";
|
|
80
|
+
try {
|
|
81
|
+
text = readFileSync(telemetryFile, "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
/* ファイルが無ければツール実行は出さない */
|
|
84
|
+
}
|
|
85
|
+
const chunks = text.split(/\r?\n(?=\{)/);
|
|
86
|
+
for (const chunk of chunks) {
|
|
87
|
+
if (!chunk.includes("gemini_cli.tool_call")) continue;
|
|
88
|
+
let row;
|
|
89
|
+
try {
|
|
90
|
+
row = JSON.parse(chunk);
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const attrs = row?.attributes ?? row?.body?.attributes ?? row;
|
|
95
|
+
const name = attrs?.function_name ?? attrs?.["function_name"];
|
|
96
|
+
if (!name) continue;
|
|
97
|
+
// hrTime([秒, ナノ秒])で来る実装もある。取れる形から順に試す。
|
|
98
|
+
const hr = Array.isArray(row?.hrTime) ? row.hrTime[0] * 1000 : null;
|
|
99
|
+
const parts = localParts(
|
|
100
|
+
attrs?.["event.timestamp"] ?? row?.timestamp ?? row?.observedTimestamp ?? hr,
|
|
101
|
+
tzOffsetMin,
|
|
102
|
+
);
|
|
103
|
+
if (!parts || parts.date !== date) continue;
|
|
104
|
+
const masked = maskText(String(attrs?.function_args ?? "").slice(0, 300), maskPatterns);
|
|
105
|
+
maskedCount += masked.count;
|
|
106
|
+
events.push({
|
|
107
|
+
id: `${events.length}`,
|
|
108
|
+
name: normalizeToolName(String(name)),
|
|
109
|
+
summary: masked.text.slice(0, 120),
|
|
110
|
+
date: parts.date,
|
|
111
|
+
time: parts.time,
|
|
112
|
+
ms: parts.ms,
|
|
113
|
+
project: "(案件未設定)",
|
|
114
|
+
ok: typeof attrs?.success === "boolean" ? attrs.success : null,
|
|
115
|
+
});
|
|
116
|
+
sessions.add(telemetryFile);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { events, prompts, sessions, maskedCount };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export default {
|
|
124
|
+
id: "gemini-cli",
|
|
125
|
+
agentName: "Gemini",
|
|
126
|
+
origin: "Gemini CLI のローカル記録(この端末・telemetry 有効時にツール実行を含む)",
|
|
127
|
+
collect: collectGemini,
|
|
128
|
+
};
|
|
@@ -0,0 +1,427 @@
|
|
|
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, readdirSync, statSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { maskText } from "../mask.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hermes Agent のセッション記録を収集元とする。
|
|
13
|
+
*
|
|
14
|
+
* 2つの保存形式をサポート:
|
|
15
|
+
* - 旧形式: ~/.hermes/sessions/session_*.json(個別JSONファイル)
|
|
16
|
+
* - 新形式: ~/.hermes/state.db(SQLite、Hermes バージョンアップ以降)
|
|
17
|
+
*
|
|
18
|
+
* 両方が存在する場合はマージする(session ID で重複排除)。
|
|
19
|
+
*
|
|
20
|
+
* Hermes session JSON(旧形式)の構造:
|
|
21
|
+
* { session_id, model, platform, session_start, last_updated, messages: [...] }
|
|
22
|
+
* message.role = "assistant" → tool_calls[].function.{name,arguments}
|
|
23
|
+
* message.role = "tool" → tool_call_id, content(JSON string), name
|
|
24
|
+
*
|
|
25
|
+
* state.db(新形式)の構造:
|
|
26
|
+
* sessions: id, started_at(unix ts), source, tool_call_count, ...
|
|
27
|
+
* messages: id, session_id, role, tool_calls(JSON), tool_name, content, tool_call_id, timestamp(unix ts)
|
|
28
|
+
*
|
|
29
|
+
* ペアリング: assistant tool_call → 対応する tool result で成否判定。
|
|
30
|
+
* このソースが名義れるのは Hermes だけ(帰属の規律)。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const SESSIONS_DIR =
|
|
34
|
+
process.env.HERMES_SESSIONS_DIR ?? join(homedir(), ".hermes", "sessions");
|
|
35
|
+
const STATE_DB =
|
|
36
|
+
process.env.HERMES_STATE_DB ?? join(homedir(), ".hermes", "state.db");
|
|
37
|
+
|
|
38
|
+
/** ISO(UTC) → ローカルの YYYY-MM-DD / HH:MM(tzOffsetMin 分ずらして判定)。 */
|
|
39
|
+
const localParts = (iso, tzOffsetMin) => {
|
|
40
|
+
const t = Date.parse(iso);
|
|
41
|
+
if (Number.isNaN(t)) return null;
|
|
42
|
+
const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
|
|
43
|
+
return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Unix timestamp → ローカルの YYYY-MM-DD / HH:MM(tzOffsetMin 分ずらして判定)。 */
|
|
47
|
+
const localPartsFromUnix = (unixTs, tzOffsetMin) => {
|
|
48
|
+
const t = unixTs * 1000;
|
|
49
|
+
const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
|
|
50
|
+
return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** ツール引数から日報に必要な最小限の要約を取り出す(生の中身は持ち出さない)。 */
|
|
54
|
+
const summarizeArgs = (tool, argsStr) => {
|
|
55
|
+
if (!argsStr) return tool;
|
|
56
|
+
let args;
|
|
57
|
+
try {
|
|
58
|
+
args = JSON.parse(argsStr);
|
|
59
|
+
} catch {
|
|
60
|
+
return String(argsStr).slice(0, 120);
|
|
61
|
+
}
|
|
62
|
+
const clip = (s, n) =>
|
|
63
|
+
typeof s === "string" ? s.replace(/\s+/g, " ").slice(0, n) : "";
|
|
64
|
+
switch (tool) {
|
|
65
|
+
case "terminal":
|
|
66
|
+
return clip(args.command, 120);
|
|
67
|
+
case "read_file":
|
|
68
|
+
case "write_file":
|
|
69
|
+
return clip(args.path, 120);
|
|
70
|
+
case "patch":
|
|
71
|
+
return clip(args.path, 80);
|
|
72
|
+
case "web_search":
|
|
73
|
+
return clip(args.query, 100);
|
|
74
|
+
case "web_extract":
|
|
75
|
+
return clip(String(args.urls ?? ""), 100);
|
|
76
|
+
case "browser_navigate":
|
|
77
|
+
return clip(args.url, 100);
|
|
78
|
+
case "delegate_task":
|
|
79
|
+
return clip(args.goal, 100);
|
|
80
|
+
case "search_files":
|
|
81
|
+
return clip(args.pattern, 80);
|
|
82
|
+
case "skill_view":
|
|
83
|
+
return clip(args.name, 80);
|
|
84
|
+
case "memory":
|
|
85
|
+
return clip(args.content, 80);
|
|
86
|
+
case "fact_store":
|
|
87
|
+
return clip(args.content ?? args.query, 80);
|
|
88
|
+
default:
|
|
89
|
+
// 最初の有用な文字列値を返す
|
|
90
|
+
for (const val of Object.values(args)) {
|
|
91
|
+
if (typeof val === "string" && val.length < 200) return clip(val, 120);
|
|
92
|
+
}
|
|
93
|
+
return tool;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** tool result content から成否を判定。 */
|
|
98
|
+
const judgeOk = (result) => {
|
|
99
|
+
if (typeof result !== "string") return true;
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(result);
|
|
102
|
+
return !parsed.error && !parsed.is_error;
|
|
103
|
+
} catch {
|
|
104
|
+
return (
|
|
105
|
+
!result.toLowerCase().slice(0, 200).includes("error") &&
|
|
106
|
+
!result.toLowerCase().slice(0, 200).includes("failed")
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// ─── 旧形式: session_*.json ───
|
|
112
|
+
|
|
113
|
+
const collectFromJsonFiles = (config) => {
|
|
114
|
+
const { date, tzOffsetMin, maskPatterns, includePrompts } = config;
|
|
115
|
+
|
|
116
|
+
let sessionNames;
|
|
117
|
+
try {
|
|
118
|
+
sessionNames = readdirSync(SESSIONS_DIR)
|
|
119
|
+
.filter((f) => f.startsWith("session_") && f.endsWith(".json"))
|
|
120
|
+
.map((f) => f.replace(".json", ""));
|
|
121
|
+
} catch {
|
|
122
|
+
return { events: [], prompts: [], sessions: new Set(), maskedCount: 0 };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sessionNames.sort((a, b) => {
|
|
126
|
+
const ta = a.match(/(\d{8}_\d{6})/)?.[1] ?? "";
|
|
127
|
+
const tb = b.match(/(\d{8}_\d{6})/)?.[1] ?? "";
|
|
128
|
+
return ta.localeCompare(tb);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const events = [];
|
|
132
|
+
const prompts = [];
|
|
133
|
+
const sessions = new Set();
|
|
134
|
+
let maskedCount = 0;
|
|
135
|
+
|
|
136
|
+
for (const sid of sessionNames) {
|
|
137
|
+
const fpath = join(SESSIONS_DIR, `${sid}.json`);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const mtime = statSync(fpath).mtimeMs;
|
|
141
|
+
const dayStart = Date.parse(`${date}T00:00:00Z`) - tzOffsetMin * 60_000;
|
|
142
|
+
if (mtime < dayStart) continue;
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let data;
|
|
148
|
+
try {
|
|
149
|
+
data = JSON.parse(readFileSync(fpath, "utf8"));
|
|
150
|
+
} catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const updated = data.last_updated ?? data.session_start;
|
|
155
|
+
const parts = localParts(updated, tzOffsetMin);
|
|
156
|
+
if (!parts || parts.date !== date) continue;
|
|
157
|
+
|
|
158
|
+
sessions.add(sid);
|
|
159
|
+
const sessionTs = updated.includes("Z")
|
|
160
|
+
? updated.replace("Z", "+00:00")
|
|
161
|
+
: updated;
|
|
162
|
+
const eventParts = localParts(sessionTs, tzOffsetMin);
|
|
163
|
+
|
|
164
|
+
const messages = data.messages ?? [];
|
|
165
|
+
const pending = {};
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < messages.length; i++) {
|
|
168
|
+
const msg = messages[i];
|
|
169
|
+
const role = msg.role;
|
|
170
|
+
|
|
171
|
+
if (role === "assistant" && Array.isArray(msg.tool_calls)) {
|
|
172
|
+
for (const tc of msg.tool_calls) {
|
|
173
|
+
const fn = tc.function ?? {};
|
|
174
|
+
const tcId = tc.id ?? String(i);
|
|
175
|
+
pending[tcId] = { tool: fn.name ?? "unknown", args: fn.arguments ?? "" };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (role === "user" && includePrompts) {
|
|
180
|
+
const content = typeof msg.content === "string" ? msg.content : "";
|
|
181
|
+
if (content && !content.startsWith("{") && content.length > 3) {
|
|
182
|
+
const stripped = content
|
|
183
|
+
.replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, " ")
|
|
184
|
+
.replace(/<[^>]+>/g, " ")
|
|
185
|
+
.trim();
|
|
186
|
+
if (stripped) {
|
|
187
|
+
const masked = maskText(stripped, maskPatterns);
|
|
188
|
+
maskedCount += masked.count;
|
|
189
|
+
const trimmed = masked.text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
190
|
+
if (trimmed.length > 3) {
|
|
191
|
+
prompts.push({ time: eventParts?.time ?? "00:00", text: trimmed });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (role === "tool") {
|
|
198
|
+
const tcId = msg.tool_call_id ?? "";
|
|
199
|
+
const result = msg.content ?? "";
|
|
200
|
+
const nameFromResult = msg.name ?? "";
|
|
201
|
+
const p = pending[tcId];
|
|
202
|
+
if (!p) continue;
|
|
203
|
+
|
|
204
|
+
const tool = nameFromResult && nameFromResult !== "?" ? nameFromResult : p.tool;
|
|
205
|
+
const ok = judgeOk(result);
|
|
206
|
+
const summary = summarizeArgs(tool, p.args);
|
|
207
|
+
|
|
208
|
+
const masked = maskText(summary, maskPatterns);
|
|
209
|
+
maskedCount += masked.count;
|
|
210
|
+
|
|
211
|
+
events.push({
|
|
212
|
+
id: tcId,
|
|
213
|
+
name: tool,
|
|
214
|
+
summary: masked.text.slice(0, 120),
|
|
215
|
+
time: eventParts?.time ?? "00:00",
|
|
216
|
+
ms: eventParts?.ms ?? 0,
|
|
217
|
+
project: "",
|
|
218
|
+
ok,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
delete pending[tcId];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return { events, prompts, sessions, maskedCount };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// ─── 新形式: state.db (SQLite) ───
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* state.db から対象日のツール実行を収集する。
|
|
233
|
+
* node:sqlite (Node 22.5+) を使用。実験的 API だが zero-dependency。
|
|
234
|
+
*/
|
|
235
|
+
const collectFromStateDb = async (config) => {
|
|
236
|
+
const { date, tzOffsetMin, maskPatterns, includePrompts } = config;
|
|
237
|
+
|
|
238
|
+
if (!existsSync(STATE_DB)) {
|
|
239
|
+
return { events: [], prompts: [], sessions: new Set(), maskedCount: 0 };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let DatabaseSync;
|
|
243
|
+
try {
|
|
244
|
+
DatabaseSync = (await import("node:sqlite")).DatabaseSync;
|
|
245
|
+
} catch {
|
|
246
|
+
// node:sqlite が使えない環境(Node < 22.5)ではスキップ
|
|
247
|
+
return { events: [], prompts: [], sessions: new Set(), maskedCount: 0 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const events = [];
|
|
251
|
+
const prompts = [];
|
|
252
|
+
const sessions = new Set();
|
|
253
|
+
let maskedCount = 0;
|
|
254
|
+
|
|
255
|
+
let db;
|
|
256
|
+
try {
|
|
257
|
+
db = new DatabaseSync(STATE_DB, { readOnly: true });
|
|
258
|
+
} catch {
|
|
259
|
+
return { events: [], prompts: [], sessions: new Set(), maskedCount: 0 };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
// 対象日の UTC 境界(tzOffsetMin を考慮)
|
|
264
|
+
const dayStartUtc = Date.parse(`${date}T00:00:00Z`) / 1000 - tzOffsetMin * 60;
|
|
265
|
+
const dayEndUtc = dayStartUtc + 86_400;
|
|
266
|
+
|
|
267
|
+
// 対象日に「活動した」セッションを取得(開始日ではなく活動日で選ぶ)。
|
|
268
|
+
// 長く続くスレッド(例: 数日前に開始して当日も進めたセッション)を
|
|
269
|
+
// 開始日で弾くと、当日の作業が丸ごと日報から消える。
|
|
270
|
+
const sessionRows = db
|
|
271
|
+
.prepare(
|
|
272
|
+
`SELECT DISTINCT s.id, s.started_at, s.source FROM sessions s
|
|
273
|
+
JOIN messages m ON m.session_id = s.id
|
|
274
|
+
WHERE m.timestamp >= ? AND m.timestamp < ?
|
|
275
|
+
ORDER BY s.started_at`
|
|
276
|
+
)
|
|
277
|
+
.all(dayStartUtc, dayEndUtc);
|
|
278
|
+
|
|
279
|
+
for (const sRow of sessionRows) {
|
|
280
|
+
const sid = sRow.id;
|
|
281
|
+
sessions.add(sid);
|
|
282
|
+
|
|
283
|
+
// セッション内のメッセージを取得
|
|
284
|
+
const msgRows = db
|
|
285
|
+
.prepare(
|
|
286
|
+
`SELECT role, tool_name, tool_calls, content, timestamp, tool_call_id
|
|
287
|
+
FROM messages
|
|
288
|
+
WHERE session_id = ? AND role IN ('assistant', 'tool', 'user')
|
|
289
|
+
ORDER BY id`
|
|
290
|
+
)
|
|
291
|
+
.all(sid);
|
|
292
|
+
|
|
293
|
+
// assistant tool_calls → pending に積む
|
|
294
|
+
const pending = {};
|
|
295
|
+
|
|
296
|
+
for (const m of msgRows) {
|
|
297
|
+
if (m.role === "assistant" && m.tool_calls) {
|
|
298
|
+
let tcArray;
|
|
299
|
+
try {
|
|
300
|
+
tcArray = JSON.parse(m.tool_calls);
|
|
301
|
+
} catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!Array.isArray(tcArray)) continue;
|
|
305
|
+
for (const tc of tcArray) {
|
|
306
|
+
const fn = tc.function ?? {};
|
|
307
|
+
const tcId = tc.id ?? tc.call_id ?? "";
|
|
308
|
+
if (!tcId) continue;
|
|
309
|
+
pending[tcId] = {
|
|
310
|
+
tool: fn.name ?? "unknown",
|
|
311
|
+
args: fn.arguments ?? "",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (m.role === "user" && includePrompts && m.content) {
|
|
317
|
+
const content = typeof m.content === "string" ? m.content : "";
|
|
318
|
+
if (content && !content.startsWith("{") && content.length > 3) {
|
|
319
|
+
const stripped = content
|
|
320
|
+
.replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, " ")
|
|
321
|
+
.replace(/<[^>]+>/g, " ")
|
|
322
|
+
.trim();
|
|
323
|
+
if (stripped) {
|
|
324
|
+
const masked = maskText(stripped, maskPatterns);
|
|
325
|
+
maskedCount += masked.count;
|
|
326
|
+
const trimmed = masked.text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
327
|
+
if (trimmed.length > 3) {
|
|
328
|
+
const parts = localPartsFromUnix(m.timestamp, tzOffsetMin);
|
|
329
|
+
if (parts?.date === date) {
|
|
330
|
+
prompts.push({ time: parts.time, text: trimmed });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (m.role === "tool") {
|
|
338
|
+
const tcId = m.tool_call_id ?? "";
|
|
339
|
+
const result = m.content ?? "";
|
|
340
|
+
const nameFromResult = m.tool_name ?? "";
|
|
341
|
+
const p = pending[tcId];
|
|
342
|
+
if (!p) continue;
|
|
343
|
+
|
|
344
|
+
const tool =
|
|
345
|
+
nameFromResult && nameFromResult !== "?" ? nameFromResult : p.tool;
|
|
346
|
+
const ok = judgeOk(result);
|
|
347
|
+
const summary = summarizeArgs(tool, p.args);
|
|
348
|
+
|
|
349
|
+
const masked = maskText(summary, maskPatterns);
|
|
350
|
+
maskedCount += masked.count;
|
|
351
|
+
|
|
352
|
+
const parts = localPartsFromUnix(m.timestamp, tzOffsetMin);
|
|
353
|
+
|
|
354
|
+
// 複数日にまたがるセッションから、対象日以外の実行を混ぜない
|
|
355
|
+
if (parts?.date !== date) {
|
|
356
|
+
delete pending[tcId];
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
events.push({
|
|
361
|
+
id: tcId,
|
|
362
|
+
name: tool,
|
|
363
|
+
summary: masked.text.slice(0, 120),
|
|
364
|
+
time: parts?.time ?? "00:00",
|
|
365
|
+
ms: parts?.ms ?? 0,
|
|
366
|
+
project: "",
|
|
367
|
+
ok,
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
delete pending[tcId];
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} finally {
|
|
375
|
+
db.close();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return { events, prompts, sessions, maskedCount };
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// ─── 統合エントリポイント ───
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 対象日のセッション記録を読み、ツール実行を集計する。
|
|
385
|
+
* 旧形式(session_*.json)と新形式(state.db)の両方をサポート。
|
|
386
|
+
*/
|
|
387
|
+
const collectHermes = async (config) => {
|
|
388
|
+
// 新形式(state.db)から収集
|
|
389
|
+
const fromDb = await collectFromStateDb(config);
|
|
390
|
+
|
|
391
|
+
// 旧形式(session_*.json)から収集
|
|
392
|
+
const fromJson = collectFromJsonFiles(config);
|
|
393
|
+
|
|
394
|
+
// マージ(重複排除: session ID でキー)
|
|
395
|
+
const sessions = new Set([...fromDb.sessions, ...fromJson.sessions]);
|
|
396
|
+
|
|
397
|
+
// events: 両方のイベントをマージ。重複する event id は state.db 側を優先
|
|
398
|
+
const seenEventIds = new Set();
|
|
399
|
+
const events = [];
|
|
400
|
+
|
|
401
|
+
for (const e of fromDb.events) {
|
|
402
|
+
if (!seenEventIds.has(e.id)) {
|
|
403
|
+
seenEventIds.add(e.id);
|
|
404
|
+
events.push(e);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
for (const e of fromJson.events) {
|
|
408
|
+
if (!seenEventIds.has(e.id)) {
|
|
409
|
+
seenEventIds.add(e.id);
|
|
410
|
+
events.push(e);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// prompts: 両方をマージ
|
|
415
|
+
const prompts = [...fromDb.prompts, ...fromJson.prompts];
|
|
416
|
+
|
|
417
|
+
const maskedCount = fromDb.maskedCount + fromJson.maskedCount;
|
|
418
|
+
|
|
419
|
+
return { events, prompts, sessions, maskedCount };
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
export default {
|
|
423
|
+
id: "hermes",
|
|
424
|
+
agentName: "Hermes",
|
|
425
|
+
origin: "Hermes Agent のセッション記録(この端末)",
|
|
426
|
+
collect: collectHermes,
|
|
427
|
+
};
|