@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.
Files changed (70) hide show
  1. package/.env.example +88 -0
  2. package/LICENSE +21 -0
  3. package/PROVENANCE.json +77 -0
  4. package/README.md +70 -2
  5. package/bin/clubs-nippo +7 -0
  6. package/bin/clubs-nippo-web.mjs +338 -0
  7. package/bin/clubs-nippo.mjs +527 -0
  8. package/docs/events-schema.md +76 -0
  9. package/examples/hermes-events.sample.jsonl +8 -0
  10. package/package.json +48 -4
  11. package/src/aggregate.mjs +105 -0
  12. package/src/autorun.mjs +25 -0
  13. package/src/autorun.test.mjs +43 -0
  14. package/src/cards.mjs +406 -0
  15. package/src/collect.mjs +111 -0
  16. package/src/config.mjs +173 -0
  17. package/src/generators/claude.mjs +31 -0
  18. package/src/generators/cli.mjs +81 -0
  19. package/src/generators/codex.mjs +28 -0
  20. package/src/generators/copilot.mjs +21 -0
  21. package/src/generators/gemini.mjs +20 -0
  22. package/src/generators/openai-compat.mjs +79 -0
  23. package/src/generators/openrouter.mjs +65 -0
  24. package/src/history.mjs +66 -0
  25. package/src/ingest.mjs +78 -0
  26. package/src/lemma.docHash.test.mjs +37 -0
  27. package/src/lemma.mjs +303 -0
  28. package/src/mask.mjs +45 -0
  29. package/src/mcp.mjs +269 -0
  30. package/src/narrative.mjs +191 -0
  31. package/src/prompt.mjs +184 -0
  32. package/src/prompt.test.mjs +146 -0
  33. package/src/remote-config.mjs +72 -0
  34. package/src/render.mjs +467 -0
  35. package/src/send.mjs +36 -0
  36. package/src/settings.mjs +54 -0
  37. package/src/sources/claude-code.mjs +174 -0
  38. package/src/sources/codex-cli.mjs +182 -0
  39. package/src/sources/copilot-cli.mjs +125 -0
  40. package/src/sources/cowork.mjs +193 -0
  41. package/src/sources/events-file.mjs +120 -0
  42. package/src/sources/gemini-cli.mjs +128 -0
  43. package/src/sources/hermes.mjs +427 -0
  44. package/src/store.mjs +227 -0
  45. package/src/tasks.mjs +229 -0
  46. package/src/tool-name.mjs +28 -0
  47. package/templates/report-v3.reference.html +162 -0
  48. package/web/assets/logo-clubs-white.png +0 -0
  49. package/web/assets/logo-clubs-white.svg +7 -0
  50. package/web/assets/logo-clubs-white.svg.tmp +30 -0
  51. package/web/assets/logo-clubs.svg +30 -0
  52. package/web/assets/qr.js +411 -0
  53. package/web/assets/sidebar.js +154 -0
  54. package/web/assets/twemoji.js +34 -0
  55. package/web/cards/achievements.html +230 -0
  56. package/web/cards/catalog.json +217 -0
  57. package/web/email/hero-v3.reference.html +123 -0
  58. package/web/home/index.html +269 -0
  59. package/web/preview.json +301 -0
  60. package/web/qr-selftest.html +46 -0
  61. package/web/reports/edit.html +255 -0
  62. package/web/reports/edit.reference.html +257 -0
  63. package/web/reports/index.html +196 -0
  64. package/web/settings/index.html +219 -0
  65. package/web/share/cards-reference.html +228 -0
  66. package/web/share/passport.html +214 -0
  67. package/web/share/teianbako.html +177 -0
  68. package/web/signup/index.html +326 -0
  69. package/web/tasks/index.html +229 -0
  70. package/web/verify/index.html +248 -0
@@ -0,0 +1,174 @@
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 } from "node:fs";
7
+ import { join, basename } from "node:path";
8
+ import { maskText } from "../mask.mjs";
9
+ import { normalizeToolName } from "../tool-name.mjs";
10
+
11
+ /** ~/.claude/projects 配下の *.jsonl を再帰列挙(subagents/ も含む)。 */
12
+ const listTranscripts = (dir) => {
13
+ const out = [];
14
+ const walk = (d) => {
15
+ let entries;
16
+ try {
17
+ entries = readdirSync(d, { withFileTypes: true });
18
+ } catch {
19
+ return;
20
+ }
21
+ for (const e of entries) {
22
+ const p = join(d, e.name);
23
+ if (e.isDirectory()) walk(p);
24
+ else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(p);
25
+ }
26
+ };
27
+ walk(dir);
28
+ return out;
29
+ };
30
+
31
+ /** ISO(UTC) → ローカルの YYYY-MM-DD / HH:MM(tzOffsetMin 分ずらして判定)。 */
32
+ const localParts = (iso, tzOffsetMin) => {
33
+ const t = Date.parse(iso);
34
+ if (Number.isNaN(t)) return null;
35
+ const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
36
+ return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
37
+ };
38
+
39
+ /** ツール入力から、日報に必要な最小限の要約だけを取り出す(生の中身は持ち出さない)。 */
40
+ const summarizeInput = (name, input) => {
41
+ if (!input || typeof input !== "object") return "";
42
+ const clip = (s, n) => (typeof s === "string" ? s.replace(/\s+/g, " ").slice(0, n) : "");
43
+ switch (name) {
44
+ case "Bash":
45
+ return clip(input.description || input.command, 120);
46
+ case "Read":
47
+ case "Write":
48
+ case "Edit":
49
+ case "NotebookEdit":
50
+ return clip(input.file_path, 120);
51
+ case "Grep":
52
+ case "Glob":
53
+ return clip(input.pattern, 80);
54
+ case "WebFetch":
55
+ case "WebSearch":
56
+ return clip(input.url || input.query, 100);
57
+ case "Task":
58
+ case "Agent":
59
+ return clip(input.description, 100);
60
+ // ツール検索(ToolSearch)の "select:…" クエリは要約に向かないので拾わない。
61
+ case "ToolSearch":
62
+ return "";
63
+ default: {
64
+ // MCP ツール等の生 input JSON をそのまま出すと日報本文が {"keywords":…} で
65
+ // 埋まる。人が読める文字列フィールドだけ拾い、無ければ空にする。
66
+ const pick =
67
+ input.file_path ?? input.url ?? input.description ?? input.title ?? input.prompt ?? input.query;
68
+ return typeof pick === "string" && !pick.startsWith("select:") ? clip(pick, 100) : "";
69
+ }
70
+ }
71
+ };
72
+
73
+ /**
74
+ * 対象日のセッション記録を読み、ツール実行を集計する。
75
+ * 返すのは「集計+マスク済みの短い要約」だけで、tool_result の中身(生の出力)は保持しない。
76
+ */
77
+ const collectClaudeCode = (config) => {
78
+ const { date, tzOffsetMin, transcriptDir, maskPatterns, includePrompts } = config;
79
+
80
+ const files = listTranscripts(transcriptDir);
81
+ const events = [];
82
+ const prompts = [];
83
+ const errorsById = new Map();
84
+ const sessions = new Set();
85
+ let maskedCount = 0;
86
+
87
+ for (const file of files) {
88
+ // 対象日より前に最終更新されたファイルは読む必要がない(1日ぶんの窓には掛からない)。
89
+ try {
90
+ const mtime = statSync(file).mtimeMs;
91
+ const dayStart = Date.parse(`${date}T00:00:00Z`) - tzOffsetMin * 60_000;
92
+ if (mtime < dayStart) continue;
93
+ } catch {
94
+ continue;
95
+ }
96
+
97
+ let lines;
98
+ try {
99
+ lines = readFileSync(file, "utf8").split("\n");
100
+ } catch {
101
+ continue;
102
+ }
103
+
104
+ for (const line of lines) {
105
+ if (!line.trim()) continue;
106
+ let entry;
107
+ try {
108
+ entry = JSON.parse(line);
109
+ } catch {
110
+ continue;
111
+ }
112
+
113
+ const parts = localParts(entry.timestamp, tzOffsetMin);
114
+ if (!parts || parts.date !== date) continue;
115
+
116
+ const content = entry.message?.content;
117
+
118
+ // ユーザーの指示文は content が「文字列」で入る(tool_result は配列)。
119
+ // スラッシュコマンドや system-reminder 等のノイズは落とす。
120
+ if (includePrompts && entry.type === "user" && typeof content === "string") {
121
+ const stripped = content.replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, " ").replace(/<[^>]+>/g, " ");
122
+ const masked = maskText(stripped, maskPatterns);
123
+ maskedCount += masked.count;
124
+ const trimmed = masked.text.replace(/\s+/g, " ").trim().slice(0, 200);
125
+ if (trimmed.length > 3) prompts.push({ time: parts.time, text: trimmed });
126
+ continue;
127
+ }
128
+
129
+ if (!Array.isArray(content)) continue;
130
+
131
+ for (const block of content) {
132
+ if (entry.type === "assistant" && block.type === "tool_use") {
133
+ const raw = summarizeInput(block.name, block.input);
134
+ const masked = maskText(raw, maskPatterns);
135
+ maskedCount += masked.count;
136
+ events.push({
137
+ id: block.id,
138
+ name: normalizeToolName(block.name),
139
+ summary: masked.text,
140
+ time: parts.time,
141
+ ms: parts.ms,
142
+ project: basename(entry.cwd ?? "") || "unknown",
143
+ ok: null, // tool_result を見てから後で埋める
144
+ });
145
+ if (entry.sessionId) sessions.add(entry.sessionId);
146
+ }
147
+
148
+ if (entry.type === "user" && block.type === "tool_result") {
149
+ errorsById.set(block.tool_use_id, block.is_error === true);
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ // tool_result が返っていないもの(中断・進行中)は ok=null のままにし、
156
+ // 成功率の分母から外す。成否不明を成功として数えない。
157
+ for (const e of events) {
158
+ if (errorsById.has(e.id)) e.ok = !errorsById.get(e.id);
159
+ }
160
+
161
+ return { events, prompts, sessions, maskedCount };
162
+ };
163
+
164
+
165
+ /**
166
+ * Claude Code のセッション記録(~/.claude/projects/**\/*.jsonl)を収集元とする。
167
+ * このソースが名乗れるのは Claude だけ。Hermes 等の名前は付けられない(帰属の規律)。
168
+ */
169
+ export default {
170
+ id: "claude-code",
171
+ agentName: "Claude",
172
+ origin: "Claude Code のセッション記録(この端末)",
173
+ collect: collectClaudeCode,
174
+ };
@@ -0,0 +1,182 @@
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, statSync } 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
+ * OpenAI Codex CLI のセッション記録(~/.codex/sessions/**\/*.jsonl)を収集元とする。
14
+ *
15
+ * rollout 形式(1行1JSON・{timestamp, type, payload})から、
16
+ * - function_call / local_shell_call … ツール実行イベント
17
+ * - function_call_output … 成否(metadata.exit_code / success)
18
+ * - message(role=user) … オーナーの指示(先頭200字・マスク済み)
19
+ * だけを取り、コマンド全文・出力本文は保持しない(要約はマスクして120字まで)。
20
+ *
21
+ * 形式はバージョンで揺れるため防御的に読む——読めない行は無視し、成否が
22
+ * 取れないイベントは ok=null(成功率の分母から外す・無いものを在ることにしない)。
23
+ */
24
+
25
+ const SESSIONS_DIR = () =>
26
+ process.env.CODEX_DIR ?? join(homedir(), ".codex", "sessions");
27
+
28
+ const listJsonl = (dir, out = []) => {
29
+ let entries;
30
+ try {
31
+ entries = readdirSync(dir, { withFileTypes: true });
32
+ } catch {
33
+ return out;
34
+ }
35
+ for (const e of entries) {
36
+ const p = join(dir, e.name);
37
+ if (e.isDirectory()) listJsonl(p, out);
38
+ else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(p);
39
+ }
40
+ return out;
41
+ };
42
+
43
+ const localParts = (iso, tzOffsetMin) => {
44
+ const t = Date.parse(iso);
45
+ if (Number.isNaN(t)) return null;
46
+ const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
47
+ return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
48
+ };
49
+
50
+ /** function_call_output の中身から成否を推定する。判らなければ null。 */
51
+ const okFromOutput = (payload) => {
52
+ const out = payload?.output;
53
+ if (out && typeof out === "object") {
54
+ if (typeof out.success === "boolean") return out.success;
55
+ const code = out?.metadata?.exit_code;
56
+ if (typeof code === "number") return code === 0;
57
+ }
58
+ if (typeof out === "string") {
59
+ try {
60
+ const parsed = JSON.parse(out);
61
+ const code = parsed?.metadata?.exit_code;
62
+ if (typeof code === "number") return code === 0;
63
+ if (typeof parsed?.success === "boolean") return parsed.success;
64
+ } catch {
65
+ /* テキスト出力=成否を主張しない */
66
+ }
67
+ }
68
+ return null;
69
+ };
70
+
71
+ const textOf = (content) => {
72
+ if (typeof content === "string") return content;
73
+ if (Array.isArray(content)) {
74
+ return content
75
+ .map((c) => (typeof c === "string" ? c : (c?.text ?? "")))
76
+ .join(" ");
77
+ }
78
+ return "";
79
+ };
80
+
81
+ const collectCodex = (config) => {
82
+ const { date, tzOffsetMin, maskPatterns, includePrompts } = config;
83
+ const events = [];
84
+ const prompts = [];
85
+ const sessions = new Set();
86
+ const okById = new Map();
87
+ let maskedCount = 0;
88
+
89
+ const dayStartMs = Date.parse(`${date}T00:00:00Z`) - tzOffsetMin * 60_000;
90
+
91
+ for (const file of listJsonl(SESSIONS_DIR())) {
92
+ // 対象日より前に書き終わったファイルは開かない(mtime で足切り)。
93
+ try {
94
+ if (statSync(file).mtimeMs < dayStartMs) continue;
95
+ } catch {
96
+ continue;
97
+ }
98
+
99
+ let lines;
100
+ try {
101
+ lines = readFileSync(file, "utf8").split("\n");
102
+ } catch {
103
+ continue;
104
+ }
105
+
106
+ let project = "(案件未設定)";
107
+
108
+ for (const line of lines) {
109
+ if (!line.trim()) continue;
110
+ let row;
111
+ try {
112
+ row = JSON.parse(line);
113
+ } catch {
114
+ continue;
115
+ }
116
+ const payload = row?.payload ?? row;
117
+
118
+ // セッションメタから案件名(cwd の末尾)を拾う。
119
+ const cwd = payload?.cwd ?? payload?.workdir;
120
+ if (typeof cwd === "string" && cwd) {
121
+ project = cwd.split("/").filter(Boolean).pop() ?? project;
122
+ continue;
123
+ }
124
+
125
+ const parts = localParts(row?.timestamp ?? payload?.timestamp, tzOffsetMin);
126
+
127
+ const ptype = payload?.type;
128
+ if (ptype === "function_call" || ptype === "local_shell_call" || ptype === "custom_tool_call") {
129
+ if (!parts || parts.date !== date) continue;
130
+ const name = normalizeToolName(
131
+ String(payload.name ?? payload.tool ?? (ptype === "local_shell_call" ? "shell" : "tool")),
132
+ );
133
+ const argText =
134
+ typeof payload.arguments === "string"
135
+ ? payload.arguments
136
+ : JSON.stringify(payload.action ?? payload.arguments ?? "");
137
+ const masked = maskText(String(argText ?? "").slice(0, 300), maskPatterns);
138
+ maskedCount += masked.count;
139
+ const id = String(payload.call_id ?? payload.id ?? `${file}:${events.length}`);
140
+ events.push({
141
+ id,
142
+ name,
143
+ summary: masked.text.slice(0, 120),
144
+ date: parts.date,
145
+ time: parts.time,
146
+ ms: parts.ms,
147
+ project,
148
+ ok: null,
149
+ });
150
+ sessions.add(file);
151
+ continue;
152
+ }
153
+
154
+ if (ptype === "function_call_output" || ptype === "custom_tool_call_output") {
155
+ const id = String(payload.call_id ?? payload.id ?? "");
156
+ if (id) okById.set(id, okFromOutput(payload));
157
+ continue;
158
+ }
159
+
160
+ if (ptype === "message" && payload.role === "user") {
161
+ if (!includePrompts || !parts || parts.date !== date) continue;
162
+ const masked = maskText(textOf(payload.content), maskPatterns);
163
+ maskedCount += masked.count;
164
+ const trimmed = masked.text.replace(/\s+/g, " ").trim().slice(0, 200);
165
+ if (trimmed) prompts.push({ time: parts.time, text: trimmed });
166
+ }
167
+ }
168
+ }
169
+
170
+ for (const e of events) {
171
+ if (okById.has(e.id)) e.ok = okById.get(e.id);
172
+ }
173
+
174
+ return { events, prompts, sessions, maskedCount };
175
+ };
176
+
177
+ export default {
178
+ id: "codex-cli",
179
+ agentName: "Codex",
180
+ origin: "Codex CLI のセッション記録(この端末)",
181
+ collect: collectCodex,
182
+ };
@@ -0,0 +1,125 @@
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, statSync } 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
+ * GitHub Copilot CLI のセッション記録を収集元とする。
14
+ *
15
+ * Copilot CLI はセッション状態を ~/.copilot/history-session-state/ 配下の
16
+ * JSON に残す。形式は公開仕様が無くバージョンで揺れるため、**防御的に**
17
+ * 「timeline/events 配列の中の、ツール実行らしきエントリ」だけを拾う。
18
+ * 読めないものは無視(フェイクなし)。成否が取れなければ ok=null。
19
+ */
20
+
21
+ const COPILOT_DIR = () =>
22
+ process.env.COPILOT_DIR ?? join(homedir(), ".copilot");
23
+
24
+ const localParts = (isoOrMs, tzOffsetMin) => {
25
+ const t = typeof isoOrMs === "number" ? isoOrMs : Date.parse(isoOrMs);
26
+ if (Number.isNaN(t)) return null;
27
+ const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
28
+ return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
29
+ };
30
+
31
+ /** ツール実行らしきエントリか(type/tool 名の総当たりでなく代表的な形だけ)。 */
32
+ const asToolEntry = (row) => {
33
+ if (!row || typeof row !== "object") return null;
34
+ const type = String(row.type ?? row.kind ?? "").toLowerCase();
35
+ const looksTool =
36
+ type.includes("tool") || type === "function_call" || type === "command";
37
+ const name = row.tool ?? row.toolName ?? row.name ?? row.command;
38
+ if (!looksTool || !name) return null;
39
+ const ts = row.timestamp ?? row.ts ?? row.time ?? row.createdAt;
40
+ const ok =
41
+ typeof row.success === "boolean"
42
+ ? row.success
43
+ : typeof row.ok === "boolean"
44
+ ? row.ok
45
+ : row.status === "success"
46
+ ? true
47
+ : row.status === "error" || row.status === "failed"
48
+ ? false
49
+ : null;
50
+ return { name: String(name), ts, ok, summary: row.summary ?? row.title ?? "" };
51
+ };
52
+
53
+ const walkArrays = (node, visit, depth = 0) => {
54
+ if (depth > 4 || !node || typeof node !== "object") return;
55
+ if (Array.isArray(node)) {
56
+ for (const item of node) {
57
+ visit(item);
58
+ walkArrays(item, visit, depth + 1);
59
+ }
60
+ return;
61
+ }
62
+ for (const v of Object.values(node)) walkArrays(v, visit, depth + 1);
63
+ };
64
+
65
+ const collectCopilot = (config) => {
66
+ const { date, tzOffsetMin, maskPatterns } = config;
67
+ const events = [];
68
+ const prompts = [];
69
+ const sessions = new Set();
70
+ let maskedCount = 0;
71
+
72
+ const dir = join(COPILOT_DIR(), "history-session-state");
73
+ let files = [];
74
+ try {
75
+ files = readdirSync(dir)
76
+ .filter((f) => f.endsWith(".json"))
77
+ .map((f) => join(dir, f));
78
+ } catch {
79
+ /* Copilot CLI 未使用=何も出さない */
80
+ }
81
+
82
+ const dayStartMs = Date.parse(`${date}T00:00:00Z`) - tzOffsetMin * 60_000;
83
+
84
+ for (const file of files) {
85
+ try {
86
+ if (statSync(file).mtimeMs < dayStartMs) continue;
87
+ } catch {
88
+ continue;
89
+ }
90
+ let doc;
91
+ try {
92
+ doc = JSON.parse(readFileSync(file, "utf8"));
93
+ } catch {
94
+ continue;
95
+ }
96
+ walkArrays(doc, (row) => {
97
+ const entry = asToolEntry(row);
98
+ if (!entry) return;
99
+ const parts = localParts(entry.ts, tzOffsetMin);
100
+ if (!parts || parts.date !== date) return;
101
+ const masked = maskText(String(entry.summary ?? "").slice(0, 300), maskPatterns);
102
+ maskedCount += masked.count;
103
+ events.push({
104
+ id: `${file}:${events.length}`,
105
+ name: normalizeToolName(entry.name),
106
+ summary: masked.text.slice(0, 120),
107
+ date: parts.date,
108
+ time: parts.time,
109
+ ms: parts.ms,
110
+ project: "(案件未設定)",
111
+ ok: entry.ok,
112
+ });
113
+ sessions.add(file);
114
+ });
115
+ }
116
+
117
+ return { events, prompts, sessions, maskedCount };
118
+ };
119
+
120
+ export default {
121
+ id: "copilot-cli",
122
+ agentName: "Copilot",
123
+ origin: "GitHub Copilot CLI のセッション記録(この端末・実験的対応)",
124
+ collect: collectCopilot,
125
+ };
@@ -0,0 +1,193 @@
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, dirname, basename } from "node:path";
8
+ import { homedir, platform } from "node:os";
9
+ import { maskText } from "../mask.mjs";
10
+ import { normalizeToolName } from "../tool-name.mjs";
11
+
12
+ /**
13
+ * Claude デスクトップアプリ(Cowork)のセッション記録を収集元とする。
14
+ *
15
+ * 記録は次の形で置かれている:
16
+ * .../local-agent-mode-sessions/<workspace>/<space>/local_<id>/audit.jsonl ← 監査ログ
17
+ * .../local-agent-mode-sessions/<workspace>/<space>/local_<id>.json ← メタ(title 等)
18
+ *
19
+ * audit.jsonl の中身は Claude Code の transcript とほぼ同じ形
20
+ * (assistant の message.content[] に tool_use、user 側に tool_result)。
21
+ *
22
+ * 案件名には cwd を使わない。Cowork の cwd はセッションの出力ディレクトリで、
23
+ * 「どの案件か」を表さないため、隣の local_<id>.json の title を使う。
24
+ */
25
+
26
+ const DEFAULT_DIR = (() => {
27
+ if (platform() === "win32") {
28
+ // Windows: %APPDATA%\Claude\local-agent-mode-sessions
29
+ const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
30
+ return join(appData, "Claude", "local-agent-mode-sessions");
31
+ }
32
+ // macOS: ~/Library/Application Support/Claude/local-agent-mode-sessions
33
+ return join(
34
+ homedir(),
35
+ "Library",
36
+ "Application Support",
37
+ "Claude",
38
+ "local-agent-mode-sessions",
39
+ );
40
+ })();
41
+
42
+ const listAuditLogs = (dir) => {
43
+ const out = [];
44
+ const walk = (d) => {
45
+ let entries;
46
+ try {
47
+ entries = readdirSync(d, { withFileTypes: true });
48
+ } catch {
49
+ return;
50
+ }
51
+ for (const e of entries) {
52
+ const p = join(d, e.name);
53
+ if (e.isDirectory()) walk(p);
54
+ else if (e.isFile() && e.name === "audit.jsonl") out.push(p);
55
+ }
56
+ };
57
+ walk(dir);
58
+ return out;
59
+ };
60
+
61
+ /** audit.jsonl の隣にある local_<id>.json から、人が読めるセッション名を取る。 */
62
+ const sessionTitle = (auditPath) => {
63
+ const sessionDir = dirname(auditPath); // .../local_<id>
64
+ const metaPath = `${sessionDir}.json`; // .../local_<id>.json
65
+ if (!existsSync(metaPath)) return basename(sessionDir);
66
+ try {
67
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
68
+ const title = String(meta.title ?? "").trim();
69
+ return title || basename(sessionDir);
70
+ } catch {
71
+ return basename(sessionDir);
72
+ }
73
+ };
74
+
75
+ const localParts = (iso, tzOffsetMin) => {
76
+ const t = Date.parse(iso);
77
+ if (Number.isNaN(t)) return null;
78
+ const shifted = new Date(t + tzOffsetMin * 60_000).toISOString();
79
+ return { date: shifted.slice(0, 10), time: shifted.slice(11, 16), ms: t };
80
+ };
81
+
82
+ const summarizeInput = (name, input) => {
83
+ if (!input || typeof input !== "object") return "";
84
+ const clip = (s, n) => (typeof s === "string" ? s.replace(/\s+/g, " ").slice(0, n) : "");
85
+ if (name === "WebSearch") return clip(input.query, 100);
86
+ if (name === "WebFetch" || name.endsWith("web_fetch")) return clip(input.url, 100);
87
+ if (name === "TaskCreate" || name === "TaskUpdate") {
88
+ return clip(input.description ?? input.title ?? input.prompt, 100);
89
+ }
90
+ if (name === "Bash") return clip(input.description ?? input.command, 120);
91
+ // ツール検索(ToolSearch)の "select:…" クエリや MCP ツールの生 input JSON は
92
+ // 「何に触れたか」の要約に向かない。人が読める文字列フィールドだけ拾い、無ければ
93
+ // 空にする(representativeTargets が空を捨てるので日報本文に混ざらない)。生 JSON を
94
+ // そのまま出すと fallback 本文が {"keywords":…} / select:mcp__… で埋まる。
95
+ if (name === "ToolSearch") return "";
96
+ const pick = input.file_path ?? input.url ?? input.description ?? input.title ?? input.prompt ?? input.query;
97
+ return typeof pick === "string" && !pick.startsWith("select:") ? clip(pick, 100) : "";
98
+ };
99
+
100
+ const collectCowork = (config) => {
101
+ const { date, tzOffsetMin, maskPatterns, includePrompts } = config;
102
+ const dir = config.coworkDir || DEFAULT_DIR;
103
+
104
+ const events = [];
105
+ const prompts = [];
106
+ const sessions = new Set();
107
+ const errorsById = new Map();
108
+ let maskedCount = 0;
109
+
110
+ const dayStart = Date.parse(`${date}T00:00:00Z`) - tzOffsetMin * 60_000;
111
+
112
+ for (const file of listAuditLogs(dir)) {
113
+ try {
114
+ if (statSync(file).mtimeMs < dayStart) continue;
115
+ } catch {
116
+ continue;
117
+ }
118
+
119
+ const project = sessionTitle(file);
120
+
121
+ let lines;
122
+ try {
123
+ lines = readFileSync(file, "utf8").split("\n");
124
+ } catch {
125
+ continue;
126
+ }
127
+
128
+ for (const line of lines) {
129
+ if (!line.trim()) continue;
130
+ let entry;
131
+ try {
132
+ entry = JSON.parse(line);
133
+ } catch {
134
+ continue;
135
+ }
136
+
137
+ const parts = localParts(entry.timestamp, tzOffsetMin);
138
+ if (!parts || parts.date !== date) continue;
139
+
140
+ const content = entry.message?.content;
141
+
142
+ if (includePrompts && entry.type === "user" && typeof content === "string") {
143
+ const stripped = content
144
+ .replace(/<[^>]+>[\s\S]*?<\/[^>]+>/g, " ")
145
+ .replace(/<[^>]+>/g, " ");
146
+ const masked = maskText(stripped, maskPatterns);
147
+ maskedCount += masked.count;
148
+ const trimmed = masked.text.replace(/\s+/g, " ").trim().slice(0, 200);
149
+ if (trimmed.length > 3) prompts.push({ time: parts.time, text: trimmed });
150
+ continue;
151
+ }
152
+
153
+ if (!Array.isArray(content)) continue;
154
+
155
+ for (const block of content) {
156
+ if (entry.type === "assistant" && block.type === "tool_use") {
157
+ const masked = maskText(summarizeInput(block.name, block.input), maskPatterns);
158
+ maskedCount += masked.count;
159
+ events.push({
160
+ id: block.id,
161
+ name: normalizeToolName(block.name),
162
+ summary: masked.text,
163
+ time: parts.time,
164
+ ms: parts.ms,
165
+ project,
166
+ ok: null, // tool_result を見てから後で埋める
167
+ });
168
+ if (entry.session_id) sessions.add(entry.session_id);
169
+ }
170
+
171
+ if (entry.type === "user" && block.type === "tool_result") {
172
+ errorsById.set(block.tool_use_id, block.is_error === true);
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ // tool_result が返っていないもの(中断・進行中)は ok=null のままにし、
179
+ // 成功率の分母から外す。成否不明を成功として数えない。
180
+ for (const e of events) {
181
+ if (errorsById.has(e.id)) e.ok = !errorsById.get(e.id);
182
+ }
183
+
184
+ return { events, prompts, sessions, maskedCount };
185
+ };
186
+
187
+ export default {
188
+ id: "cowork",
189
+ // Cowork は Claude の一面。Claude Code と同じ名義で、1通の日報にまとまる。
190
+ agentName: "Claude",
191
+ origin: "Claude デスクトップアプリ(Cowork)のセッション記録",
192
+ collect: collectCowork,
193
+ };