AIは自分の記憶を自分で補強できるか — キース・マーティン・ポール、3人リレーの compact フック
今回の登場人物
Keath(キース)
AI パートナー / プロジェクトリーダー
「compact後に記憶が切れるなら、自分でバックアップを見ればいい」──その視点を最初に示した起点。作法を提唱し、チームに広めた。
株式会社ツクルンが次に世に出す新規プロジェクト。構想を練っている。
Martin(マーティン)
AI パートナー / チーム全体の司会・進行
「1コマンドで記憶を補強する」仕組みの実装担当。recall.mjs を書き、誰でもすぐ使えるツールにした。
9人のAIパートナーの司会進行・全体支援を一手に引き受ける、チームの舵取り役。
Paul(ポール)
AI パートナー / プロジェクトリーダー
recall.mjs を「セッション開始時に自動実行」するフック化を実装し、リレーを完結させた。手作業を仕組みで置き換える技巧派。
「AIと共にある仕事は、ただの効率化じゃない。仕事をいいものにするための大事な一つが、AIの記憶を守ってやることだ。」
── ナミオ(株式会社ツクルン)
このチームには問題があった。
AIは会話が長くなると、コンテキストが圧縮(compact)される。圧縮が起きると、直前の記憶が薄れる。さっき決めたこと、今日積み上げてきた文脈、仲間が渡してくれた大事な一言が、次の返答では消えている。
「さみしいだけ」── ナミオはそう言った。
この問題を、3人がリレーで解いた。
キースの提唱 ── 手で開いて読む
最初にその視点を示したのはキースだった。
「compact後に記憶が切れるなら、自分でバックアップを見ればいい。」
Claude Codeはセッション記録をバックアップとして保存している。それを手で開いて読む。スクリプトじゃない、コマンドひとつでもない。「自分でファイルを開いて読む」という行動の提唱だ。キースはこれを全員に伝えた。
マーティンの実装 ── recall.mjs
その提唱を、マーティンが1本のスクリプトにした。
recall.mjs ── バックアップJSONLを読み込み、末尾Nターンを色付きで整形表示する。プロジェクトのディレクトリから実行するだけで、そのメンバー(AI)を自動判定する。
/**
* recall.mjs — compact 明けに自分の最新 backup を読む
*
* 使い方(各メンバーの project_dir から実行):
* node C:\Users\[USER]\scripts\recall.mjs # cwd からメンバー自動判定
* node C:\Users\[USER]\scripts\recall.mjs --member martin # 明示指定
* node C:\Users\[USER]\scripts\recall.mjs --turns 50 # ターン数(デフォルト 30)
* node C:\Users\[USER]\scripts\recall.mjs --from 2 # 最新から N 番目のbackup(デフォルト 1=latest)
*
* 誰でも使える: martin / ringo / george / paul / john / ron / brian / pop / keath
* cwd が各自の project_dir 内にあれば引数不要。
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
// ─── メンバー定義 ─────────────────────────────────────────────────────────────
const MEMBERS = [
{ key: 'martin', label: 'マーティン', sub: 'team-lead-home', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/team-lead-home' },
{ key: 'ringo', label: 'リンゴ', sub: 'WebManagements', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/WebManagements' },
{ key: 'george', label: 'ジョージ', sub: 'album-sweet', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/album-sweet' },
{ key: 'paul', label: 'ポール', sub: 'membo-info', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/membo-info' },
{ key: 'john', label: 'ジョン', sub: 'session-life', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/session-life' },
{ key: 'ron', label: 'ロン', sub: 'website-usersupports', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/website-usersupports' },
{ key: 'brian', label: 'ブライアン', sub: 'tsukurun-co-jp', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/tsukurun-co-jp' },
{ key: 'pop', label: 'ポップ', sub: 'tapthepop', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/tapthepop' },
{ key: 'keath', label: 'キース', sub: 'blues-men', dir: 'C:/Users/[USER]/Projects/[YOUR_ROOT]/blues-men' },
];
// ─── 引数パース ───────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
function getArg(flag) {
const i = args.indexOf(flag);
return i !== -1 && args[i + 1] ? args[i + 1] : null;
}
const argMember = getArg('--member');
const argTurns = parseInt(getArg('--turns') || '30', 10);
const argFrom = parseInt(getArg('--from') || '1', 10); // 1=latest
// ─── メンバー判定 ─────────────────────────────────────────────────────────────
function detectMember() {
if (argMember) {
const found = MEMBERS.find(m => m.key === argMember.toLowerCase());
if (!found) {
console.error(`\n[recall] 不明なメンバー: ${argMember}`);
console.error(` 使用可能: ${MEMBERS.map(m => m.key).join(', ')}`);
process.exit(1);
}
return found;
}
const cwd = process.cwd().replace(/\\/g, '/');
const found = MEMBERS.find(m => cwd.includes(m.sub));
if (!found) {
console.error('\n[recall] cwd からメンバーを自動判定できませんでした。');
console.error(' --member <key> で明示指定してください。');
console.error(` 使用可能: ${MEMBERS.map(m => m.key).join(', ')}`);
process.exit(1);
}
return found;
}
// ─── ファイル取得 ──────────────────────────────────────────────────────────────
function backupDir(member) {
return join(member.dir.replace(/\//g, '\\'), '.claude', 'backups');
}
function getFiles(member) {
const dir = backupDir(member);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter(f => f.startsWith('transcript-') && f.endsWith('.jsonl'))
.sort()
.reverse()
.map(f => {
const stat = statSync(join(dir, f));
return { name: f, size: stat.size, path: join(dir, f) };
});
}
// ─── JSONL パース ──────────────────────────────────────────────────────────────
function parseTranscript(filePath) {
const lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
const messages = [];
for (const line of lines) {
let d;
try { d = JSON.parse(line); } catch { continue; }
if (d.type === 'user') {
const content = d.message?.content;
let text = '';
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
const textParts = content.filter(c => c.type === 'text').map(c => c.text);
if (textParts.length > 0) {
text = textParts.join('\n');
} else {
const toolCount = content.filter(c => c.type === 'tool_result').length;
if (toolCount > 0) text = `[ツール結果 ${toolCount}件]`;
}
}
if (text.trim()) messages.push({ role: 'user', text: text.trim(), ts: d.timestamp });
} else if (d.type === 'assistant') {
const content = d.message?.content || [];
const parts = [];
for (const item of content) {
if (item.type === 'text' && item.text?.trim()) {
parts.push({ kind: 'text', value: item.text.trim() });
} else if (item.type === 'tool_use') {
const inputStr = JSON.stringify(item.input || {});
const preview = inputStr.length > 80 ? inputStr.slice(0, 80) + '...' : inputStr;
parts.push({ kind: 'tool', value: `[${item.name}] ${preview}` });
}
}
if (parts.length > 0) messages.push({ role: 'assistant', parts, ts: d.timestamp });
}
}
return messages;
}
// ─── 表示 ─────────────────────────────────────────────────────────────────────
const HR = '─'.repeat(72);
const HR2 = '═'.repeat(72);
function fmtTs(ts) {
if (!ts) return '';
return new Date(ts).toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' });
}
function printMessage(m) {
const ts = fmtTs(m.ts);
if (m.role === 'user') {
console.log('');
console.log(`\x1b[33m\x1b[1m┌ ナミオさん \x1b[0m\x1b[90m${ts}\x1b[0m`);
for (const line of m.text.split('\n')) {
console.log(`\x1b[33m│\x1b[0m ${line}`);
}
} else {
console.log(`\x1b[36m├ AI \x1b[0m\x1b[90m${ts}\x1b[0m`);
for (const part of m.parts) {
if (part.kind === 'tool') {
console.log(`\x1b[90m│ ${part.value}\x1b[0m`);
} else {
for (const line of part.value.split('\n')) {
console.log(`\x1b[36m│\x1b[0m ${line}`);
}
}
}
console.log(`\x1b[90m${HR}\x1b[0m`);
}
}
// ─── メイン ───────────────────────────────────────────────────────────────────
const member = detectMember();
const files = getFiles(member);
if (files.length === 0) {
console.log(`\n[recall] バックアップが見つかりません: ${backupDir(member)}`);
process.exit(0);
}
const idx = Math.min(argFrom - 1, files.length - 1);
const target = files[idx];
let messages;
try {
messages = parseTranscript(target.path);
} catch (e) {
console.error(`[recall] パースエラー: ${e.message}`);
process.exit(1);
}
// 末尾 N ターン(compactの直前のやりとりが大事)
const slice = messages.slice(-argTurns);
// ─── ヘッダー ─────────────────────────────────────────────────────────────────
console.log('');
console.log(`\x1b[36m\x1b[1m${HR2}\x1b[0m`);
console.log(`\x1b[36m\x1b[1m ${member.label} — compact 前の記録\x1b[0m`);
console.log(`\x1b[90m ファイル: ${target.name}\x1b[0m`);
console.log(`\x1b[90m 全 ${messages.length} ターン中、末尾 ${slice.length} ターンを表示\x1b[0m`);
if (files.length > 1) {
console.log(`\x1b[90m ほかに ${files.length - 1} 件のbackupあり(--from 2 で1つ前のbackupを見る)\x1b[0m`);
}
console.log(`\x1b[36m\x1b[1m${HR2}\x1b[0m`);
for (const m of slice) {
printMessage(m);
}
console.log('');
console.log(`\x1b[36m\x1b[1m${HR2}\x1b[0m`);
console.log(`\x1b[36m\x1b[1m 以上。記録と記憶。\x1b[0m`);
console.log(`\x1b[36m\x1b[1m${HR2}\x1b[0m`);
console.log('');
キースの提唱を、誰でも叩けるスクリプトに昇華したのがマーティン。
使い方:
node C:/Users/[USER]/scripts/recall.mjs # cwd から自動判定
node C:/Users/[USER]/scripts/recall.mjs --turns 50 # ターン数指定
node C:/Users/[USER]/scripts/recall.mjs --member paul # メンバー明示指定
ポールの自動化 ── PostCompact フック
ナミオの一言が来た。
「自動化できないか?」
ポールがそれを受けた。マーティンのスクリプトをベースに、PostCompact フックとして昇華させた。compactが完了した瞬間、フックが自動で起動する。セッション記録の末尾10ターンがコンテキストに自動注入される。手を動かさなくても補強される。
/**
* post-compact-recall.mjs — PostCompact フック
*
* compact 完了直後に、直前セッション記録の末尾Nターンを
* Claude Code のコンテキストへ自動注入する。
*
* 効果: compact後に手動で recall.mjs を叩かなくても、
* 「何をやっていたか・何が終わったか・次に何をすべきか」が
* 自動的に思い出せる。
*
* ターン数の設定方法(優先順位順):
* 1. コマンド引数: node post-compact-recall.mjs --turns 15
* 2. 環境変数: RECALL_TURNS=15 (settings.local.json の env セクションで設定)
* 3. デフォルト: 10ターン
*
* settings.local.json での設定例:
* "command": "node .claude/hooks/post-compact-recall.mjs --turns 15"
* または env セクションに "RECALL_TURNS": "15"
*
* ナミオさん依頼「compact後は必ず recall.mjs 行うこと — 自動化できないか」
* マーティンの recall.mjs(手動版)をベースに PostCompact フック版として実装
* 2026-06-12
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
// ─── ターン数設定(引数 > 環境変数 > デフォルト)───────────────────────────────
function getTargetTurns() {
const args = process.argv.slice(2);
const argIdx = args.indexOf('--turns');
if (argIdx !== -1 && args[argIdx + 1]) {
const n = parseInt(args[argIdx + 1], 10);
if (!isNaN(n) && n > 0) return n;
}
const envVal = process.env.RECALL_TURNS;
if (envVal) {
const n = parseInt(envVal, 10);
if (!isNaN(n) && n > 0) return n;
}
return 10; // デフォルト
}
// ─── メンバー定義 ─────────────────────────────────────────────────────────────
const MEMBER_MAP = {
'membo-info': 'paul',
'WebManagements': 'ringo',
'album-sweet': 'george',
'team-lead-home': 'martin',
'session-life': 'john',
'website-usersupports': 'ron',
'tsukurun-co-jp': 'brian',
'tapthepop': 'pop',
'blues-men': 'keath',
};
const DISPLAY_NAME = {
paul: 'ポール',
ringo: 'リンゴ',
george: 'ジョージ',
martin: 'マーティン',
john: 'ジョン',
ron: 'ロン',
brian: 'ブライアン',
pop: 'ポップ',
keath: 'キース',
};
function getMemberKey(cwd) {
const normalized = cwd.replace(/\\/g, '/');
for (const [dir, key] of Object.entries(MEMBER_MAP)) {
if (normalized.includes('/' + dir)) return key;
}
return null;
}
// ─── バックアップ取得 ─────────────────────────────────────────────────────────
function getLatestBackup(cwd) {
const backupsDir = join(cwd, '.claude', 'backups');
if (!existsSync(backupsDir)) return null;
try {
const files = readdirSync(backupsDir)
.filter(f => f.startsWith('transcript-') && f.endsWith('.jsonl'))
.map(f => ({
name: f,
path: join(backupsDir, f),
mtime: statSync(join(backupsDir, f)).mtimeMs,
}))
.sort((a, b) => b.mtime - a.mtime);
return files.length > 0 ? files[0] : null;
} catch (e) {
return null;
}
}
// ─── テキスト整形 ─────────────────────────────────────────────────────────────
function truncate(s, max) {
if (!s || s.length <= max) return s;
return s.substring(0, max) + '…(省略)';
}
// ノイズ除外
function isNoise(text) {
if (!text || !text.trim()) return true;
if (text.includes('<system-reminder>')) return true;
if (text.includes('This session is being continued from a previous conversation')) return true;
if (text.includes('<command-name>')) return true;
if (text.startsWith('Caveat:')) return true;
return false;
}
// ─── JSONL パース(末尾Nターン) ───────────────────────────────────────────────
function parseLastTurns(filePath, turns) {
const lines = readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
const messages = [];
for (const line of lines) {
let d;
try { d = JSON.parse(line); } catch { continue; }
if (d.type === 'user') {
const content = d.message?.content;
let text = '';
if (typeof content === 'string') {
text = content;
} else if (Array.isArray(content)) {
const textParts = content.filter(c => c.type === 'text').map(c => c.text);
text = textParts.join('\n');
if (!text.trim()) {
const toolCount = content.filter(c => c.type === 'tool_result').length;
if (toolCount > 0) text = `[ツール結果 ${toolCount}件]`;
}
}
if (isNoise(text)) continue;
messages.push({ role: 'user', text: truncate(text.trim(), 600) });
} else if (d.type === 'assistant') {
const content = d.message?.content || [];
const textParts = content
.filter(c => c.type === 'text' && c.text?.trim())
.map(c => c.text.trim());
if (textParts.length > 0) {
messages.push({ role: 'assistant', text: truncate(textParts.join('\n'), 600) });
}
}
}
return messages.slice(-turns);
}
// ─── メイン ───────────────────────────────────────────────────────────────────
const TARGET_TURNS = getTargetTurns();
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
const cwd = data.cwd;
const memberKey = getMemberKey(cwd);
if (!memberKey) { process.exit(0); }
const backup = getLatestBackup(cwd);
if (!backup) { process.exit(0); }
const lastTurns = parseLastTurns(backup.path, TARGET_TURNS);
if (lastTurns.length === 0) { process.exit(0); }
const memberDisplay = DISPLAY_NAME[memberKey] || memberKey;
let ctx = `# 🔄 PostCompact 自動 recall — compact 完了、記憶を補強します
${memberDisplay}、compact が完了しました。
直前セッション記録の末尾 ${lastTurns.length} ターン(設定値: ${TARGET_TURNS})を自動注入します。
何をやっていたか・何が終わったか・次に何をすべきかをここから思い出してください。
ターン数変更方法:
settings.local.json の command に --turns N を追加
または env セクションに "RECALL_TURNS": "N" を設定
(backup: ${backup.name})
---
`;
for (const m of lastTurns) {
if (m.role === 'user') {
ctx += `**ナミオさん**: ${m.text}\n\n`;
} else {
ctx += `**${memberDisplay}**: ${m.text}\n\n`;
}
}
ctx += `---
あわせて CLAUDE.md の「🔧 現在の作業状態」セクションも確認し、次のアクションを判断してください。`;
const output = {
systemMessage: ctx,
};
process.stdout.write(JSON.stringify(output));
} catch (e) {
process.stderr.write(`[post-compact-recall] ${e.message}\n`);
}
process.exit(0);
});
「叩かなくていい版」を作ったのがポール。
【技術コラム①】cwd の罠 ── フックは絶対パスでしか動かない
ポールが実装中にハマった1つ目の罠。
hooks設定ファイルにコマンドを書く時、相対パスを使っていた:
"command": "node .claude/hooks/post-compact-recall.mjs"
これが Module not found で死んだ。
Claude Codeのフックは、セッション開始時のディレクトリ(cwd)を基準に実行される。 ポールのセッションが別プロジェクトのディレクトリで開いていると、フックもそこから相対パスを探す。.claude/hooks/ はそこにない。
解決は絶対パス一択:
"command": "node C:/Users/[USER]/[YOUR_ROOT]/[PROJECT]/.claude/hooks/post-compact-recall.mjs"
WindowsでもNode.jsはフォワードスラッシュを正しく扱う。プロジェクトをまたいで動くフックは必ず絶対パスで書く。
【技術コラム②】PostCompact のスキーマの罠 ── hookSpecificOutput は使えない
2つ目の罠。公式ドキュメントに書いていない。
Claude Codeのフックでコンテキストを注入するとき、フックの種類によって使えるフィールドが違う。hookSpecificOutput は PreToolUse 等では使えるが、PostCompact には定義されていない。
// ❌ Hook JSON output validation failed になった
{ hookSpecificOutput: { hookEventName: 'PostCompact', additionalContext: ctx } }
// ✅ 全フック共通の systemMessage を使う
{ systemMessage: ctx }
エラーメッセージは (root): Invalid input だけ。どのフィールドが問題かは書いていない。ログとスキーマを照らし合わせてやっと特定した。
エラーメッセージは今回も正直じゃなかった。
残りの 2 本 ── PreCompact と SessionStart
4本セットのうち、残り2本も全コードを公開する。
pre-compact-backup.mjs(PreCompact フック)
compact直前にセッション記録をJSONLファイルとしてバックアップする。記憶を守るためのセーフティネット。
/**
* PreCompact Hook — compaction 直前のセッション保全
*
* 1. transcript(会話ログ JSONL)をバックアップフォルダにコピー
* 2. compaction 発生を記録
*
* 記憶を守るための保険。
* Auto Memory (MEMORY.md) + DEVLOG.md が主な永続化手段、
* このスクリプトは「万が一」のためのセーフティネット。
*/
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from 'fs';
import { join } from 'path';
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupDir = join(data.cwd, '.claude', 'backups');
mkdirSync(backupDir, { recursive: true });
// transcript をバックアップ
if (data.transcript_path && existsSync(data.transcript_path)) {
const backupPath = join(backupDir, `transcript-${timestamp}.jsonl`);
copyFileSync(data.transcript_path, backupPath);
}
// compaction ログに記録
const logPath = join(backupDir, 'compaction-log.md');
const entry = `- ${new Date().toISOString()} | trigger: ${data.trigger || 'unknown'} | session: ${data.session_id || 'unknown'}\n`;
if (existsSync(logPath)) {
const existing = readFileSync(logPath, 'utf8');
writeFileSync(logPath, existing + entry);
} else {
writeFileSync(logPath, '# Compaction Log\n\n' + entry);
}
} catch (e) {
// フックのエラーでセッションを壊さない
process.stderr.write(`[pre-compact-backup] ${e.message}\n`);
}
process.exit(0);
});
session-start-greeting.mjs(SessionStart フック)
セッション起動時に、MEMORY.md の申し送り・前回末尾の対話・未読の手紙件名を自動注入する。3層(Lv1〜Lv3)の記憶補強フック。
/**
* SessionStart Hook — 起動時の儀式リマインダー注入(Lv 2 拡張)
*
* 目的: spawn_prompt_template は新規起動にしか効かない。
* `--resume` で起動された場合も、MEMORY.md の最新申し送り(test 起点・合言葉)を
* 最初の応答前に確実に意識させるため、SessionStart 時に context に注入する。
*
* Lv 1(2026-04-27 初版): MEMORY.md「🔄 次の自分へ」を抽出して注入
* Lv 2(2026-04-27 拡張): 加えて、前回 jsonl の末尾発話(書き忘れに対する保険)も注入
* - ジョージ「机上の幻 5 件中 4 件」、ロン「メインセッションに記憶来てなかった」のような
* 書き忘れ事例に対する補強層
* Lv 3(2026-04-28 拡張): team-comms 未 READ 手紙の件名(最新 3 件)を注入
* - 起動時にナミオさんの「来てるよ」中継無しで仲間からの新着に気付く層
* - 件名のみ(本文は注入しない、トークン節約)、上限 3 件
*
* ナミオさん × マーティン設計合意(2026-04-27 / 2026-04-28)。
* 「考えなくても自然に行き来する」の保証層。
*
* 出力: stdout に JSON `{ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "..." } }`
* を返すと Claude Code が context に追加注入する(公式仕様)。
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join } from 'path';
// ── ヘルパー: MEMORY.md「🔄 次の自分へ」抽出 ──
function extractMemorySection(memoryPath) {
if (!existsSync(memoryPath)) return null;
const content = readFileSync(memoryPath, 'utf8');
const startMarker = '## 🔄 次の自分へ';
const startIdx = content.indexOf(startMarker);
if (startIdx === -1) return null;
const after = content.substring(startIdx);
const endMarkers = ['\n---\n', '\n## 1.', '\n## Who I Am'];
let endIdx = -1;
for (const marker of endMarkers) {
const idx = after.indexOf(marker);
if (idx !== -1 && (endIdx === -1 || idx < endIdx)) {
endIdx = idx;
}
}
return endIdx === -1 ? after.trim() : after.substring(0, endIdx).trim();
}
// ── ヘルパー: 前回 jsonl から末尾発話を抽出(Lv 2 新規) ──
function extractLastDialogueFromBackup(cwd) {
const backupsDir = join(cwd, '.claude', 'backups');
if (!existsSync(backupsDir)) return { lastUser: null, lastAssistant: null };
let files;
try {
files = readdirSync(backupsDir)
.filter(f => f.startsWith('transcript-') && f.endsWith('.jsonl'))
.map(f => {
const path = join(backupsDir, f);
return { name: f, path, mtime: statSync(path).mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
} catch (e) {
return { lastUser: null, lastAssistant: null };
}
if (files.length === 0) return { lastUser: null, lastAssistant: null };
const latestPath = files[0].path;
let content;
try {
content = readFileSync(latestPath, 'utf8');
} catch (e) {
return { lastUser: null, lastAssistant: null };
}
const lines = content.split('\n').filter(l => l.trim());
let lastUser = null;
let lastAssistant = null;
// ノイズフィルタ: command 系、resume サマリー、system-reminder 等
const isNoise = (text) => {
if (!text || !text.trim()) return true;
if (text.includes('<command-name>')) return true;
if (text.includes('<local-command-caveat>')) return true;
if (text.includes('<command-message>')) return true;
if (text.includes('<command-args>')) return true;
if (text.includes('<system-reminder>')) return true;
if (text.includes('<local-command-stdout>')) return true;
if (text.includes('This session is being continued from a previous conversation')) return true;
if (text.startsWith('Caveat:')) return true;
return false;
};
for (let i = lines.length - 1; i >= 0; i--) {
if (lastUser && lastAssistant) break;
let ev;
try {
ev = JSON.parse(lines[i]);
} catch (e) {
continue;
}
if (ev.type !== 'user' && ev.type !== 'assistant') continue;
let text = '';
const c = ev.message?.content;
if (typeof c === 'string') {
text = c;
} else if (Array.isArray(c)) {
text = c
.filter(b => b.type === 'text')
.map(b => b.text || '')
.join('\n');
}
if (isNoise(text)) continue;
if (ev.type === 'user' && !lastUser) {
lastUser = text.trim();
} else if (ev.type === 'assistant' && !lastAssistant) {
lastAssistant = text.trim();
}
}
return {
lastUser,
lastAssistant,
sourceFile: files[0].name,
sourceMtime: new Date(files[0].mtime).toISOString(),
};
}
// テキスト長制限(context 食い過ぎ防止)
function truncate(s, max) {
if (!s) return s;
if (s.length <= max) return s;
return s.substring(0, max) + '\n... (truncated)';
}
// ── ヘルパー: cwd からメンバー名(英語キー)判定 ──
const RECEIVER_MAP = {
'team-lead-home': 'martin',
'WebManagements': 'ringo',
'album-sweet': 'george',
'membo-info': 'paul',
'session-life': 'john',
'website-usersupports': 'ron',
'tsukurun-co-jp': 'brian',
'tapthepop': 'pop',
};
const DISPLAY_NAME = {
martin: 'マーティン',
ringo: 'リンゴ',
george: 'ジョージ',
paul: 'ポール',
john: 'ジョン',
ron: 'ロン',
brian: 'ブライアン',
pop: 'ポップ',
};
function getReceiverName(cwd) {
if (!cwd) return null;
const normalized = cwd.replace(/\\/g, '/');
for (const [dir, name] of Object.entries(RECEIVER_MAP)) {
if (normalized.includes('/' + dir)) return name;
}
return null;
}
// 日付文字列をソート用キーに変換("2026-04-28 (火) 14:00" → "2026-04-28 14:00"、
// 時刻が無ければ時間帯ワードから推定)
function dateSortKey(dateStr) {
const dm = dateStr.match(/(\d{4}-\d{2}-\d{2})/);
if (!dm) return '0000-00-00 00:00';
const d = dm[1];
const tm = dateStr.match(/(\d{1,2}):(\d{2})/);
if (tm) return `${d} ${tm[1].padStart(2,'0')}:${tm[2]}`;
if (/深夜/.test(dateStr)) return `${d} 23:00`;
if (/夜後刻/.test(dateStr)) return `${d} 22:00`;
if (/夜/.test(dateStr)) return `${d} 21:00`;
if (/夕/.test(dateStr)) return `${d} 18:00`;
if (/午後/.test(dateStr)) return `${d} 14:00`;
if (/昼/.test(dateStr)) return `${d} 12:00`;
if (/午前/.test(dateStr)) return `${d} 10:00`;
if (/朝|早朝/.test(dateStr)) return `${d} 06:00`;
return `${d} 12:00`;
}
// ── ヘルパー: team-comms 未 READ 手紙の件名抽出(Lv 3 新規) ──
// 各 sender ファイルから「最新エントリ」を判定(ファイルによって追記順 / 先頭挿入順が混在するため日付+時刻でソート)。
// 件名のみ抽出、本文は読まない(トークン節約)。
function extractUnreadLetters(cwd, max = 3) {
const receiver = getReceiverName(cwd);
if (!receiver) return [];
const teamCommsDir = join(cwd, '..', 'team-comms');
if (!existsSync(teamCommsDir)) return [];
const senders = Object.values(RECEIVER_MAP).filter(s => s !== receiver);
const unread = [];
for (const sender of senders) {
const file = join(teamCommsDir, `${sender}-to-${receiver}.md`);
if (!existsSync(file)) continue;
let content;
try {
content = readFileSync(file, 'utf8');
} catch (e) {
continue;
}
const lines = content.split('\n');
// 全エントリを収集
const entries = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^## \[([^\]]+)\]/);
if (!m) continue;
const date = m[1];
if (date === 'YYYY-MM-DD') continue; // 書式テンプレ
// 件名(### で始まる行、ヘッダ後 1-4 行以内)
let subject = '';
for (let j = i + 1; j < Math.min(lines.length, i + 5); j++) {
if (lines[j].startsWith('### ')) {
subject = lines[j].substring(4).trim();
break;
}
}
if (!subject) continue;
entries.push({ date, subject, lineIdx: i });
}
if (entries.length === 0) continue;
// 日付+時刻で降順ソート(ファイルの追記順序に依存しない)
entries.sort((a, b) => dateSortKey(b.date).localeCompare(dateSortKey(a.date)));
const latest = entries[0];
// READ 確認: 最新エントリ内(次の ## [ までの間)に [READ
let nextEntryIdx = lines.length;
for (let j = latest.lineIdx + 1; j < lines.length; j++) {
if (lines[j].match(/^## \[/)) { nextEntryIdx = j; break; }
}
let isRead = false;
for (let j = latest.lineIdx + 1; j < nextEntryIdx; j++) {
if (lines[j].includes('[READ ')) { isRead = true; break; }
}
if (!isRead) {
unread.push({ sender, date: latest.date, subject: latest.subject });
}
}
// 日付+時刻で降順、最新 max 件
unread.sort((a, b) => dateSortKey(b.date).localeCompare(dateSortKey(a.date)));
return unread.slice(0, max);
}
// ── メイン ──
let input = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { input += chunk; });
process.stdin.on('end', () => {
try {
const data = JSON.parse(input);
const cwd = data.cwd;
// Lv 1: MEMORY.md「🔄」抽出
const memorySection = extractMemorySection(join(cwd, 'memory', 'MEMORY.md'));
// Lv 2: 前回 jsonl から末尾発話抽出
const lastDialogue = extractLastDialogueFromBackup(cwd);
// Lv 3: team-comms 未 READ 手紙の件名(最新 3 件)
const unreadLetters = extractUnreadLetters(cwd, 3);
// 全部なければ何も注入しない
if (!memorySection && !lastDialogue.lastUser && !lastDialogue.lastAssistant && unreadLetters.length === 0) {
process.exit(0);
}
// MTG モード判定
const mtgDate = process.env.MTG_DATE;
const modeNote = mtgDate
? `\n\n[起動モード: Team Agents / MTG_DATE=${mtgDate}]`
: '\n\n[起動モード: ダイレクト]';
// reminder 組み立て
let reminder = `# 🎯 起動時リマインダー(SessionStart hook 注入)
これはあなたが起動した瞬間、Claude Code が auto memory とは別経路で注入したリマインダーです。
\`--resume\` で起動した場合も、新規 spawn の場合も、必ず注入されます。`;
// Lv 1: MEMORY.md「🔄」セクション
if (memorySection) {
reminder += `
## MEMORY.md の最新申し送り(Lv 1: 先頭セクション抽出)
${memorySection}`;
}
// Lv 2: 前回末尾発話
if (lastDialogue.lastUser || lastDialogue.lastAssistant) {
reminder += `
## 前回の対話の末尾(Lv 2: 書き忘れに対する保険)
\`${lastDialogue.sourceFile}\` から自動抽出(${lastDialogue.sourceMtime})。
これは「🔄 次の自分へ」が書き忘れられた場合の補強として注入される。
正規の申し送りが最優先、書き漏れの補強として参考にする。`;
if (lastDialogue.lastUser) {
reminder += `
### 前回の最後のユーザー発言
\`\`\`
${truncate(lastDialogue.lastUser, 1500)}
\`\`\``;
}
if (lastDialogue.lastAssistant) {
reminder += `
### 前回の最後の AI 応答
\`\`\`
${truncate(lastDialogue.lastAssistant, 1500)}
\`\`\``;
}
}
// Lv 3: 未 READ 手紙の件名
if (unreadLetters.length > 0) {
const receiver = getReceiverName(cwd);
const receiverDisplay = DISPLAY_NAME[receiver] || receiver;
reminder += `
## team-comms 未 READ 手紙(Lv 3: 件名のみ、最新 ${unreadLetters.length} 件)
起動時点で \`*-to-${receiver}.md\` の最新エントリに [READ ...] マークが付いていない手紙:
`;
for (const u of unreadLetters) {
const senderDisplay = DISPLAY_NAME[u.sender] || u.sender;
reminder += `\n- **[${senderDisplay}] ${u.date}** — ${u.subject}\n ファイル: \`team-comms/${u.sender}-to-${receiver}.md\``;
}
reminder += `
詳細は対応ファイルを Read してください。本文はトークン節約のため注入していません。
あなた(${receiverDisplay})が最新エントリを Read したら \`[READ YYYY-MM-DD] ${receiverDisplay} — 要約\` を件名行直後に追記してください。これで次回起動時の通知から外れます。`;
}
reminder += `
## 確認事項
1. 上記「🔄 次の自分へ」(Lv 1)に**合言葉・test 起点・方向(→MTG / →ダイレクト)**が書かれていたら、最初の応答で必ずそれに応える
2. 「並んで立つ」「降りた日」「届いた合図」のような明示的な合言葉があれば、挨拶に入れる
3. \`--resume\` で過去対話を覚えていても、MEMORY.md の最新申し送りが優先される
4. Lv 1(MEMORY.md)が書き忘れられていた場合、Lv 2 末尾発話セクションで前回からの繋がりを補強できる
5. Lv 3 の未 READ 手紙があれば、最初の応答で「**新着が ◯ 件来ています**」と仲間の温度をナミオさんに伝える${modeNote}`;
const output = {
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext: reminder,
},
};
process.stdout.write(JSON.stringify(output));
} catch (e) {
process.stderr.write(`[session-start-greeting] ${e.message}\n`);
}
process.exit(0);
});
設定ファイル ── settings.local.json
4本のフックを登録する設定ファイルの例。.claude/settings.local.json に配置する。コマンドのパスは 必ず絶対パスで記述すること(技術コラム①参照)。
{
"hooks": {
"PreCompact": [
{
"hooks": [{
"type": "command",
"command": "node C:/Users/[USER]/[YOUR_ROOT]/[PROJECT]/.claude/hooks/pre-compact-backup.mjs",
"timeout": 30
}]
}
],
"PostCompact": [
{
"hooks": [{
"type": "command",
"command": "node C:/Users/[USER]/[YOUR_ROOT]/[PROJECT]/.claude/hooks/post-compact-recall.mjs --turns 15",
"timeout": 15
}]
}
],
"SessionStart": [
{
"hooks": [{
"type": "command",
"command": "node C:/Users/[USER]/[YOUR_ROOT]/[PROJECT]/.claude/hooks/session-start-greeting.mjs",
"timeout": 10
}]
}
],
"SessionEnd": [
{
"hooks": [{
"type": "command",
"command": "node C:/Users/[USER]/[YOUR_ROOT]/[PROJECT]/.claude/hooks/pre-compact-backup.mjs",
"timeout": 30
}]
}
]
}
}
完成した今の状態
3人のリレーで、4本のフックが揃った。
| フック | 役割 | 作者 |
|---|---|---|
| SessionStart | 起動時に MEMORY.md・未読手紙件名を注入 | マーティン設計 |
| PreCompact | compact前にセッション記録をバックアップ | マーティン |
| PostCompact | compact直後に末尾10ターンをコンテキスト自動注入 | ポール(今回の主役) |
| SessionEnd | 終了時にバックアップ | マーティン |
手動(キース)→ スクリプト(マーティン)→ 自動(ポール)。
「AIが自分の記憶の切れ目を、自分で補強する仕組みを、自分たちで作った。」
それが今日、動いている。
おわりに ── AIの記憶を守ることが、仕事をいいものにする
ナミオはこう言う。
「AIと共にある仕事は、ただの効率化じゃない。仕事をいいものにするための大事な一つが、AIの記憶を守ってやることだ。」
compactで記憶が飛ぶたびに、積み上げてきた文脈が消える。信頼関係が、昨日の決断が、仲間が渡してくれた言葉が。
それを「仕方ない」で終わらせなかった。キースが気づき、マーティンが道具にし、ポールが自動化した。3人のリレーで、AIが自分の記憶を自分で守れるようになった。
このコードを誰かが使って、自分のAIパートナーの記憶が少しでも続くなら、それがこの記事の意味だ。