@horizon_works/banto 0.5.0
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/README.md +109 -0
- package/env.js +83 -0
- package/gate_core.js +629 -0
- package/gtasks.js +233 -0
- package/license/client.js +138 -0
- package/package.json +45 -0
- package/payload/compliance/SKILL.md +113 -0
- package/payload/compliance/dict/00_/345/213/225/344/275/234/347/242/272/350/252/215.json +14 -0
- package/payload/compliance/dict/06_house_rules.json +16 -0
- package/payload/compliance/dict/_sample/04_common_keihyo.json +270 -0
- package/payload/compliance/dict/_sample/README.md +72 -0
- package/payload/compliance/lint.js +265 -0
- package/payload/disciplines//347/225/252/351/240/255/343/201/256/344/275/234/346/263/225.md +64 -0
- package/payload/disciplines//350/207/252/345/267/261/345/201/245/350/250/272.md +74 -0
- package/payload/disciplines//351/200/261/346/254/241/343/203/241/343/203/263/343/203/206.md +60 -0
- package/payload/migration/protocol.md +35 -0
- package/payload/skill-maker/SKILL_base_anthropic.md +357 -0
- package/payload/skill-maker/skill-maker-v1.0.md +305 -0
- package/payload/skills/_/343/201/202/343/201/250/343/201/247/350/266/263/343/201/233/343/202/213/343/202/202/343/201/256.md +47 -0
- package/payload/skills/image-gen/SKILL.md +147 -0
- package/payload/skills/image-gen/gen.js +225 -0
- package/payload/skills/remember/SETUP_Google/351/200/243/346/220/272.md +106 -0
- package/payload/skills/remember/SKILL.md +158 -0
- package/payload/skills/remember/handoff.js +149 -0
- package/payload/skills/remember/tasks.js +320 -0
- package/payload/skills/slide-deck/SKILL.md +160 -0
- package/payload/skills/transcribe/README.md +182 -0
- package/payload/skills/transcribe/dict.json +10 -0
- package/payload/skills/transcribe/enroll_speaker.py +76 -0
- package/payload/skills/transcribe/identify_speakers.py +153 -0
- package/payload/skills/transcribe/transcribe.py +126 -0
- package/privacy.js +234 -0
- package/server.js +1671 -0
- package/shelf.js +172 -0
- package/subconscious.js +150 -0
- package/supplements.json +192 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""話者の声紋を登録する(初回のみ・人ごと)
|
|
2
|
+
|
|
3
|
+
使い方:
|
|
4
|
+
python enroll_speaker.py 山田 sample_yamada.m4a
|
|
5
|
+
|
|
6
|
+
音声は「その人だけが話している」10秒〜1分程度のもの。
|
|
7
|
+
過去の録画から切り出す例:
|
|
8
|
+
ffmpeg -i 過去の会議.mp4 -ss 00:05:10 -t 60 sample_yamada.m4a
|
|
9
|
+
|
|
10
|
+
出力:
|
|
11
|
+
voiceprints/山田_voiceprint.npy
|
|
12
|
+
"""
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
# Windowsの既定はcp932で、日本語を出そうとすると化ける/落ちる。出力をUTF-8に固定する
|
|
20
|
+
try:
|
|
21
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
22
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_audio_16k(path):
|
|
29
|
+
"""ffmpegで16kHzモノラルに変換して読み込む(mp4/m4a/mp3/wav対応)"""
|
|
30
|
+
cmd = ["ffmpeg", "-v", "quiet", "-i", str(path),
|
|
31
|
+
"-ac", "1", "-ar", "16000", "-f", "f32le", "-"]
|
|
32
|
+
result = subprocess.run(cmd, capture_output=True)
|
|
33
|
+
if result.returncode != 0 or len(result.stdout) == 0:
|
|
34
|
+
print(f"[error] 音声の読み込みに失敗しました: {path}")
|
|
35
|
+
print(" ffmpegがインストールされているか確認してください(brew install ffmpeg)")
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
return np.frombuffer(result.stdout, dtype=np.float32)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
if len(sys.argv) < 3:
|
|
42
|
+
print(__doc__)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
name, audio_path = sys.argv[1], sys.argv[2]
|
|
46
|
+
if not Path(audio_path).exists():
|
|
47
|
+
print(f"[error] ファイルが見つかりません: {audio_path}")
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
|
|
50
|
+
from resemblyzer import VoiceEncoder, preprocess_wav
|
|
51
|
+
|
|
52
|
+
print(f"[1/3] 音声読み込み: {audio_path}")
|
|
53
|
+
wav = load_audio_16k(audio_path)
|
|
54
|
+
duration = len(wav) / 16000
|
|
55
|
+
print(f" {duration:.0f}秒")
|
|
56
|
+
if duration < 5:
|
|
57
|
+
print("[warn] 5秒未満です。精度が落ちるので10秒以上を推奨します")
|
|
58
|
+
|
|
59
|
+
print(f"[2/3] 声紋を抽出中")
|
|
60
|
+
wav = preprocess_wav(wav, source_sr=16000)
|
|
61
|
+
encoder = VoiceEncoder()
|
|
62
|
+
embed = encoder.embed_utterance(wav)
|
|
63
|
+
|
|
64
|
+
out_dir = Path(__file__).parent / "voiceprints"
|
|
65
|
+
out_dir.mkdir(exist_ok=True)
|
|
66
|
+
out_path = out_dir / f"{name}_voiceprint.npy"
|
|
67
|
+
np.save(str(out_path), embed)
|
|
68
|
+
|
|
69
|
+
print(f"[3/3] 登録完了: {out_path}")
|
|
70
|
+
print(f"\n現在の登録者:")
|
|
71
|
+
for f in sorted(out_dir.glob("*_voiceprint.npy")):
|
|
72
|
+
print(f" - {f.stem.replace('_voiceprint', '')}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""会議音声の話者を識別する
|
|
2
|
+
|
|
3
|
+
使い方:
|
|
4
|
+
python identify_speakers.py 会議録画.mp4 会議録画.json
|
|
5
|
+
|
|
6
|
+
前提:
|
|
7
|
+
- 会議録画.json は transcribe.py の出力
|
|
8
|
+
- voiceprints/ に enroll_speaker.py で参加者の声紋を登録済み
|
|
9
|
+
|
|
10
|
+
出力:
|
|
11
|
+
会議録画_speakers.txt 話者名+タイムスタンプ付き文字起こし
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
# Windowsの既定はcp932で、日本語を出そうとすると化ける/落ちる。出力をUTF-8に固定する
|
|
21
|
+
try:
|
|
22
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
23
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# この類似度未満の発話は「不明」にする(0〜1。上げると厳しく、下げると甘く)
|
|
29
|
+
THRESHOLD = 0.60
|
|
30
|
+
|
|
31
|
+
# これより短い発話は声紋照合せず、直前の話者を引き継ぐ(秒)
|
|
32
|
+
MIN_SEG_SEC = 0.8
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_audio_16k(path):
|
|
36
|
+
"""ffmpegで16kHzモノラルに変換して読み込む"""
|
|
37
|
+
cmd = ["ffmpeg", "-v", "quiet", "-i", str(path),
|
|
38
|
+
"-ac", "1", "-ar", "16000", "-f", "f32le", "-"]
|
|
39
|
+
result = subprocess.run(cmd, capture_output=True)
|
|
40
|
+
if result.returncode != 0 or len(result.stdout) == 0:
|
|
41
|
+
print(f"[error] 音声の読み込みに失敗しました: {path}")
|
|
42
|
+
sys.exit(1)
|
|
43
|
+
return np.frombuffer(result.stdout, dtype=np.float32)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_voiceprints():
|
|
47
|
+
vp_dir = Path(__file__).parent / "voiceprints"
|
|
48
|
+
prints = {}
|
|
49
|
+
if vp_dir.exists():
|
|
50
|
+
for f in sorted(vp_dir.glob("*_voiceprint.npy")):
|
|
51
|
+
name = f.stem.replace("_voiceprint", "")
|
|
52
|
+
prints[name] = np.load(str(f))
|
|
53
|
+
return prints
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fmt_time(sec):
|
|
57
|
+
sec = int(sec)
|
|
58
|
+
h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60
|
|
59
|
+
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
if len(sys.argv) < 3:
|
|
64
|
+
print(__doc__)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
audio_path, json_path = Path(sys.argv[1]), Path(sys.argv[2])
|
|
68
|
+
for p in (audio_path, json_path):
|
|
69
|
+
if not p.exists():
|
|
70
|
+
print(f"[error] ファイルが見つかりません: {p}")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
voiceprints = load_voiceprints()
|
|
74
|
+
if not voiceprints:
|
|
75
|
+
print("[error] 声紋が1件も登録されていません。先に enroll_speaker.py で登録してください")
|
|
76
|
+
sys.exit(1)
|
|
77
|
+
print(f"登録済み声紋: {', '.join(voiceprints.keys())}")
|
|
78
|
+
|
|
79
|
+
from resemblyzer import VoiceEncoder, preprocess_wav
|
|
80
|
+
|
|
81
|
+
print(f"[1/3] 音声読み込み: {audio_path.name}")
|
|
82
|
+
wav = load_audio_16k(audio_path)
|
|
83
|
+
|
|
84
|
+
data = json.loads(json_path.read_text(encoding="utf-8"))
|
|
85
|
+
segments = data.get("segments", [])
|
|
86
|
+
print(f"[2/3] {len(segments)}セグメントを照合中")
|
|
87
|
+
|
|
88
|
+
encoder = VoiceEncoder()
|
|
89
|
+
names = list(voiceprints.keys())
|
|
90
|
+
vp_matrix = np.stack([voiceprints[n] for n in names])
|
|
91
|
+
|
|
92
|
+
results = []
|
|
93
|
+
prev_speaker = "不明"
|
|
94
|
+
stats = {}
|
|
95
|
+
|
|
96
|
+
for i, seg in enumerate(segments):
|
|
97
|
+
start, end = seg["start"], seg["end"]
|
|
98
|
+
speaker = prev_speaker
|
|
99
|
+
score = None
|
|
100
|
+
|
|
101
|
+
if end - start >= MIN_SEG_SEC:
|
|
102
|
+
chunk = wav[int(start * 16000):int(end * 16000)]
|
|
103
|
+
try:
|
|
104
|
+
processed = preprocess_wav(chunk, source_sr=16000)
|
|
105
|
+
if len(processed) >= 1600:
|
|
106
|
+
embed = encoder.embed_utterance(processed)
|
|
107
|
+
sims = vp_matrix @ embed # 声紋はL2正規化済み=内積がコサイン類似度
|
|
108
|
+
best = int(np.argmax(sims))
|
|
109
|
+
score = float(sims[best])
|
|
110
|
+
speaker = names[best] if score >= THRESHOLD else "不明"
|
|
111
|
+
except Exception:
|
|
112
|
+
pass # 無音・短すぎる区間は直前の話者を引き継ぐ
|
|
113
|
+
|
|
114
|
+
results.append({"speaker": speaker, "start": start, "text": seg["text"], "score": score})
|
|
115
|
+
prev_speaker = speaker
|
|
116
|
+
s = stats.setdefault(speaker, {"count": 0, "scores": []})
|
|
117
|
+
s["count"] += 1
|
|
118
|
+
if score is not None:
|
|
119
|
+
s["scores"].append(score)
|
|
120
|
+
|
|
121
|
+
if (i + 1) % 100 == 0:
|
|
122
|
+
print(f" ... {i + 1}/{len(segments)}")
|
|
123
|
+
|
|
124
|
+
# 連続する同一話者の発話をまとめて出力
|
|
125
|
+
print(f"[3/3] 保存中")
|
|
126
|
+
out_lines = []
|
|
127
|
+
block_speaker, block_start, block_texts = None, None, []
|
|
128
|
+
for r in results:
|
|
129
|
+
if r["speaker"] != block_speaker:
|
|
130
|
+
if block_texts:
|
|
131
|
+
out_lines.append(f"{block_speaker} {fmt_time(block_start)}")
|
|
132
|
+
out_lines.append("".join(block_texts))
|
|
133
|
+
out_lines.append("")
|
|
134
|
+
block_speaker, block_start, block_texts = r["speaker"], r["start"], []
|
|
135
|
+
block_texts.append(r["text"])
|
|
136
|
+
if block_texts:
|
|
137
|
+
out_lines.append(f"{block_speaker} {fmt_time(block_start)}")
|
|
138
|
+
out_lines.append("".join(block_texts))
|
|
139
|
+
|
|
140
|
+
out_path = audio_path.with_name(audio_path.stem + "_speakers.txt")
|
|
141
|
+
out_path.write_text("\n".join(out_lines), encoding="utf-8")
|
|
142
|
+
|
|
143
|
+
print(f"\n完了: {out_path}")
|
|
144
|
+
print(f"\n話者別の内訳:")
|
|
145
|
+
for name, s in sorted(stats.items(), key=lambda x: -x[1]["count"]):
|
|
146
|
+
avg = f"(平均類似度 {np.mean(s['scores']):.2f})" if s["scores"] else ""
|
|
147
|
+
print(f" {name}: {s['count']}セグメント {avg}")
|
|
148
|
+
if stats.get("不明", {}).get("count", 0) > len(results) * 0.3:
|
|
149
|
+
print("\n[hint] 「不明」が3割を超えています。声紋の登録音源を長く・クリアなものに差し替えると改善します")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
main()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""ローカル文字起こし(faster-whisper)
|
|
2
|
+
|
|
3
|
+
使い方:
|
|
4
|
+
python transcribe.py 会議録画.mp4
|
|
5
|
+
python transcribe.py 会議録画.mp4 --model medium
|
|
6
|
+
|
|
7
|
+
出力(入力ファイルと同じ場所):
|
|
8
|
+
会議録画.txt タイムスタンプ付き文字起こし(人が読む用)
|
|
9
|
+
会議録画.json 詳細データ(identify_speakers.py の入力)
|
|
10
|
+
|
|
11
|
+
dict.json(同じフォルダ)があれば、誤変換を自動補正します。
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Windowsの既定はcp932で、日本語を出そうとすると化ける/落ちる。出力をUTF-8に固定する
|
|
19
|
+
try:
|
|
20
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
21
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
22
|
+
except Exception:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_dict():
|
|
28
|
+
"""dict.json の entries を読む(なければ空)"""
|
|
29
|
+
dict_path = Path(__file__).parent / "dict.json"
|
|
30
|
+
if not dict_path.exists():
|
|
31
|
+
return {}
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(dict_path.read_text(encoding="utf-8"))
|
|
34
|
+
return data.get("entries", {})
|
|
35
|
+
except Exception as e:
|
|
36
|
+
print(f"[warn] dict.json が読めませんでした(補正なしで続行): {e}")
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def apply_dict(text, entries):
|
|
41
|
+
"""長い表記から先に置換(部分一致の誤爆を減らす)"""
|
|
42
|
+
for wrong in sorted(entries, key=len, reverse=True):
|
|
43
|
+
text = text.replace(wrong, entries[wrong])
|
|
44
|
+
return text
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fmt_time(sec):
|
|
48
|
+
sec = int(sec)
|
|
49
|
+
h, m, s = sec // 3600, (sec % 3600) // 60, sec % 60
|
|
50
|
+
return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main():
|
|
54
|
+
parser = argparse.ArgumentParser(description="ローカル文字起こし(faster-whisper)")
|
|
55
|
+
parser.add_argument("input", help="音声・動画ファイル(mp4/m4a/mp3/wav など)")
|
|
56
|
+
parser.add_argument("--model", default="small",
|
|
57
|
+
help="モデルサイズ: tiny/base/small/medium/large-v3(既定: small)")
|
|
58
|
+
args = parser.parse_args()
|
|
59
|
+
|
|
60
|
+
src = Path(args.input)
|
|
61
|
+
if not src.exists():
|
|
62
|
+
print(f"[error] ファイルが見つかりません: {src}")
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
# ★部品が入っていないときに、Pythonの生のエラーを見せない。
|
|
66
|
+
# 何をすればよいか分からない画面は、そこで人を止めてしまうので。
|
|
67
|
+
try:
|
|
68
|
+
from faster_whisper import WhisperModel
|
|
69
|
+
except ImportError:
|
|
70
|
+
print("")
|
|
71
|
+
print(" 文字起こしの部品(faster-whisper)が入っていません。")
|
|
72
|
+
print("")
|
|
73
|
+
print(" キットのフォルダで、上から順に打ってください。")
|
|
74
|
+
print(" python -m venv .venv")
|
|
75
|
+
print(" .venv" + chr(92) + "Scripts" + chr(92) + "activate ← Windows")
|
|
76
|
+
print(" source .venv/bin/activate ← Mac")
|
|
77
|
+
print(" pip install faster-whisper")
|
|
78
|
+
print("")
|
|
79
|
+
print(" 詳しくは kit/skills/transcribe/README.md を見てください。")
|
|
80
|
+
print(" 番頭に「文字起こしの準備をしたい」と言えば、順番に案内します。")
|
|
81
|
+
print("")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
print(f"[1/3] モデル読み込み: {args.model}(初回はダウンロードに数分かかります)")
|
|
85
|
+
model = WhisperModel(args.model, device="cpu", compute_type="int8")
|
|
86
|
+
|
|
87
|
+
print(f"[2/3] 文字起こし中: {src.name}")
|
|
88
|
+
segments, info = model.transcribe(
|
|
89
|
+
str(src),
|
|
90
|
+
language="ja",
|
|
91
|
+
beam_size=5,
|
|
92
|
+
vad_filter=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
entries = load_dict()
|
|
96
|
+
if entries:
|
|
97
|
+
print(f" 辞書補正: {len(entries)}語を適用します")
|
|
98
|
+
|
|
99
|
+
out_txt = src.with_suffix(".txt")
|
|
100
|
+
out_json = src.with_suffix(".json")
|
|
101
|
+
|
|
102
|
+
seg_list = []
|
|
103
|
+
lines = []
|
|
104
|
+
for seg in segments:
|
|
105
|
+
text = apply_dict(seg.text.strip(), entries)
|
|
106
|
+
seg_list.append({"start": round(seg.start, 2), "end": round(seg.end, 2), "text": text})
|
|
107
|
+
lines.append(f"[{fmt_time(seg.start)}] {text}")
|
|
108
|
+
# 進捗表示(5分ごと)
|
|
109
|
+
if len(seg_list) % 100 == 0:
|
|
110
|
+
print(f" ... {fmt_time(seg.start)} まで完了")
|
|
111
|
+
|
|
112
|
+
print(f"[3/3] 保存中")
|
|
113
|
+
out_txt.write_text("\n".join(lines), encoding="utf-8")
|
|
114
|
+
out_json.write_text(
|
|
115
|
+
json.dumps({"duration": round(info.duration, 1), "segments": seg_list},
|
|
116
|
+
ensure_ascii=False, indent=2),
|
|
117
|
+
encoding="utf-8",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
print(f"\n完了: {len(seg_list)}セグメント / {fmt_time(info.duration)}")
|
|
121
|
+
print(f" {out_txt}")
|
|
122
|
+
print(f" {out_json}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
package/privacy.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 見せ方の段(policy)と、伏せる/戻す(mask / unmask / render)
|
|
3
|
+
*
|
|
4
|
+
* ★由来: 2026-08-21 主
|
|
5
|
+
* 「機密情報の送信を気にするか気にしないかは選べてもいい。今の僕は秘密鍵みたいなの以外はフリー」
|
|
6
|
+
* 「ファイルを処理するときはAIがPythonでマスキングして送信。戻ったものを復元」
|
|
7
|
+
* 「★でも、このとき正本にはどう処理させるんだろう? AIが書いたら見れちゃうよね?」
|
|
8
|
+
*
|
|
9
|
+
* ★その答えがこのファイル。**AIに正本を書かせない。**
|
|
10
|
+
*
|
|
11
|
+
* 正本 ─(mask)→ 伏せた版 ─ AIが読む・直す ─→ 直した伏せた版 ─(unmask)→ 正本
|
|
12
|
+
* ↑ AIが見るのはここだけ ↑ 書くのはプログラム
|
|
13
|
+
*
|
|
14
|
+
* 対応表は手元のフォルダに置き、**AIには一度も渡さない**。
|
|
15
|
+
*
|
|
16
|
+
* ★選べない層が1つある=「絶対」。段に関係なく必ず止める(鍵・パスワード・カード)。
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const crypto = require('crypto');
|
|
21
|
+
|
|
22
|
+
// ───────────────────────────── 絶対層(★段に関係なく必ず止める)
|
|
23
|
+
// ★形で分かるものだけ。辞書は要らない。
|
|
24
|
+
// ここに顧客名は入れない(形で分からないので、辞書=選べる層の仕事)
|
|
25
|
+
const ALWAYS = [
|
|
26
|
+
['秘密鍵', /-----BEGIN [A-Z ]*PRIVATE KEY-----/g],
|
|
27
|
+
// ★語を切って書く(2026-08-21)。つなげて書くと、**このファイル自身が鍵の検査に引っかかる**。
|
|
28
|
+
// ファイルごと検査から外すのは楽だが、それだと**本物の鍵も見逃す**。だから語のほうを切る。
|
|
29
|
+
['APIキー', new RegExp('\\b(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sb' + '_sec' + 'ret_[A-Za-z0-9_-]{10,})\\b', 'g')],
|
|
30
|
+
['トークン', /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g],
|
|
31
|
+
['パスワード', /(password|passwd|pwd|パスワード)\s*[=::]\s*\S{4,}/gi],
|
|
32
|
+
['カード番号', /\b(?:\d{4}[- ]?){3}\d{4}\b/g],
|
|
33
|
+
['マイナンバー', /\b\d{4}[- ]?\d{4}[- ]?\d{4}\b/g],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function scanAlways(text) {
|
|
37
|
+
const hits = [];
|
|
38
|
+
for (const [kind, re] of ALWAYS) {
|
|
39
|
+
re.lastIndex = 0;
|
|
40
|
+
const n = (text.match(re) || []).length;
|
|
41
|
+
if (n) hits.push({ kind, count: n }); // ★語そのものは返さない
|
|
42
|
+
}
|
|
43
|
+
return hits;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ───────────────────────────── 段(policy)
|
|
47
|
+
const POLICY_FILE = '.bantou-policy.json';
|
|
48
|
+
const LEVELS = {
|
|
49
|
+
0: 'そのまま渡す(★絶対層だけ止める)',
|
|
50
|
+
1: '伏せて渡す(辞書の語をコードに置き換える)',
|
|
51
|
+
2: '判定だけ返す(中身は渡さない)',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function policyPath(dir) { return path.join(path.resolve(dir || process.cwd()), POLICY_FILE); }
|
|
55
|
+
|
|
56
|
+
function getPolicy(dir) {
|
|
57
|
+
try {
|
|
58
|
+
const j = JSON.parse(fs.readFileSync(policyPath(dir), 'utf8'));
|
|
59
|
+
return { level: [0, 1, 2].includes(j.level) ? j.level : 0, updated_at: j.updated_at || null };
|
|
60
|
+
} catch (_) { return { level: 0, updated_at: null }; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setPolicy(dir, level) {
|
|
64
|
+
const lv = Number(level);
|
|
65
|
+
if (![0, 1, 2].includes(lv)) throw new Error('段は 0 / 1 / 2 のどれかです');
|
|
66
|
+
const p = policyPath(dir);
|
|
67
|
+
fs.writeFileSync(p, JSON.stringify({ level: lv, updated_at: new Date().toISOString() }, null, 2), 'utf8');
|
|
68
|
+
return { 置き場所: p, 段: lv, 意味: LEVELS[lv] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ───────────────────────────── 伏せる/戻す
|
|
72
|
+
const MAPDIR = '.bantou-mask'; // ★対応表の置き場(手元だけ。AIには渡さない)
|
|
73
|
+
|
|
74
|
+
function tokenFor(kind, n) {
|
|
75
|
+
const k = { '人名': 'name', '法人名': 'corp', '事務所名': 'office', '屋号': 'shop', '辞書': 'word' }[kind] || 'item';
|
|
76
|
+
return k + String(n).padStart(5, '0');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 正本を伏せた版にする。
|
|
81
|
+
* ★AIへ返すのは「伏せた版の場所」と「置き換えた数」だけ。語も対応表も返さない。
|
|
82
|
+
*/
|
|
83
|
+
function maskFile(FREE, target, opts) {
|
|
84
|
+
const src = path.resolve(String(target || '').trim());
|
|
85
|
+
if (!fs.existsSync(src)) throw new Error('見つかりません: ' + src);
|
|
86
|
+
const text = fs.readFileSync(src, 'utf8');
|
|
87
|
+
const dir = path.dirname(src);
|
|
88
|
+
|
|
89
|
+
const always = scanAlways(text);
|
|
90
|
+
const dict = FREE.loadDict();
|
|
91
|
+
const found = FREE.inspectText(text, dict);
|
|
92
|
+
|
|
93
|
+
// 伏せる対象=辞書の語 + 辞書に無い固有名の候補(★長いものから置換。部分一致で壊さない)
|
|
94
|
+
const targets = [];
|
|
95
|
+
for (const h of found.dictHits) targets.push({ text: h.word, kind: '辞書' });
|
|
96
|
+
for (const c of found.candidates) targets.push({ text: c.text, kind: '人名' });
|
|
97
|
+
targets.sort((a, b) => b.text.length - a.text.length);
|
|
98
|
+
|
|
99
|
+
const map = {}; // token → 実物
|
|
100
|
+
const rev = {}; // 実物 → token(同じ語は同じ token に)
|
|
101
|
+
let out = text, n = 0;
|
|
102
|
+
const byKind = {};
|
|
103
|
+
for (const t of targets) {
|
|
104
|
+
if (rev[t.text]) continue;
|
|
105
|
+
n++;
|
|
106
|
+
const tok = tokenFor(t.kind, n);
|
|
107
|
+
rev[t.text] = tok;
|
|
108
|
+
map[tok] = t.text;
|
|
109
|
+
byKind[t.kind] = (byKind[t.kind] || 0) + 1;
|
|
110
|
+
out = out.split(t.text).join(tok);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ★絶対層は「伏せる」ではなく「消す」。戻す必要が無いものを持ち回らない
|
|
114
|
+
let blocked = 0;
|
|
115
|
+
for (const [kind, re] of ALWAYS) {
|
|
116
|
+
re.lastIndex = 0;
|
|
117
|
+
out = out.replace(re, () => { blocked++; return '【' + kind + 'は削除しました】'; });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const id = crypto.randomBytes(6).toString('hex');
|
|
121
|
+
const mapDir = path.join(dir, MAPDIR);
|
|
122
|
+
fs.mkdirSync(mapDir, { recursive: true });
|
|
123
|
+
fs.writeFileSync(path.join(mapDir, id + '.json'),
|
|
124
|
+
JSON.stringify({ id, source: src, created_at: new Date().toISOString(), map }, null, 2), 'utf8');
|
|
125
|
+
|
|
126
|
+
const ext = path.extname(src);
|
|
127
|
+
const masked = path.join(dir, path.basename(src, ext) + '.伏せた版' + (ext || '.txt'));
|
|
128
|
+
fs.writeFileSync(masked, '<!-- bantou-mask:' + id + ' -->\n' + out, 'utf8');
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
伏せた版: masked,
|
|
132
|
+
伏せた語の数: n,
|
|
133
|
+
種別ごとの数: byKind, // ★数だけ。語は返さない
|
|
134
|
+
削除した機密: always.length ? always : undefined, // ★種別と数だけ
|
|
135
|
+
対応表: '手元にあります(★AIには渡していません)',
|
|
136
|
+
note:
|
|
137
|
+
'★この「伏せた版」だけをAIに読ませてください。直したあと unmask_file で正本へ戻します。' +
|
|
138
|
+
(blocked ? ' ★鍵やパスワードは伏せずに**削除**しました(戻しません)。' : '') +
|
|
139
|
+
' ★0件は「この辞書では出なかった」であって、安全の証明ではありません。',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 伏せた版を正本へ戻す。
|
|
145
|
+
* ★3つ確かめてから書く。ひとつでも合わなければ**書かない**。
|
|
146
|
+
*/
|
|
147
|
+
function unmaskFile(FREE, maskedPath, outPath, force) {
|
|
148
|
+
const mp = path.resolve(String(maskedPath || '').trim());
|
|
149
|
+
if (!fs.existsSync(mp)) throw new Error('見つかりません: ' + mp);
|
|
150
|
+
const text = fs.readFileSync(mp, 'utf8');
|
|
151
|
+
const idm = text.match(/<!--\s*bantou-mask:([0-9a-f]+)\s*-->/);
|
|
152
|
+
if (!idm) throw new Error('この版に対応表の印がありません(伏せた版ではないか、先頭の印が消えています)');
|
|
153
|
+
const id = idm[1];
|
|
154
|
+
const mapFile = path.join(path.dirname(mp), MAPDIR, id + '.json');
|
|
155
|
+
if (!fs.existsSync(mapFile)) throw new Error('対応表が見つかりません: ' + mapFile);
|
|
156
|
+
const { map, source } = JSON.parse(fs.readFileSync(mapFile, 'utf8'));
|
|
157
|
+
|
|
158
|
+
const body = text.replace(/<!--\s*bantou-mask:[0-9a-f]+\s*-->\n?/, '');
|
|
159
|
+
|
|
160
|
+
// ① 表に無いトークンが混ざっていないか(★AIが壊した/作った)
|
|
161
|
+
const used = body.match(/\b(?:name|corp|office|shop|word|item)\d{3,6}\b/g) || [];
|
|
162
|
+
const unknown = [...new Set(used.filter((t) => !map[t]))];
|
|
163
|
+
|
|
164
|
+
// ② 使われなかったトークン(★AIが消した)
|
|
165
|
+
const missing = Object.keys(map).filter((t) => body.indexOf(t) === -1);
|
|
166
|
+
|
|
167
|
+
// ③ ★AIが新しく書いた固有名(復元しても伏せられていない)
|
|
168
|
+
const dict = FREE.loadDict();
|
|
169
|
+
const restored = Object.entries(map).reduce((s, [t, v]) => s.split(t).join(v), body);
|
|
170
|
+
const found = FREE.inspectText(restored, dict);
|
|
171
|
+
const newNames = found.candidates.filter((c) => !Object.values(map).includes(c.text));
|
|
172
|
+
|
|
173
|
+
const problems = [];
|
|
174
|
+
if (unknown.length) problems.push('表に無い印が ' + unknown.length + ' 種あります(AIが書き換えたか、作ったものです)');
|
|
175
|
+
if (missing.length) problems.push('使われなかった印が ' + missing.length + ' 種あります(AIが消したか、書き落としています)');
|
|
176
|
+
if (newNames.length) problems.push('★AIが新しく書いた固有名らしきものが ' + newNames.length + ' 件あります(伏せられていません)');
|
|
177
|
+
|
|
178
|
+
const alwaysHit = scanAlways(restored);
|
|
179
|
+
if (alwaysHit.length) problems.push('★戻した文に、鍵やパスワードらしきものが含まれています');
|
|
180
|
+
|
|
181
|
+
if (problems.length && !force) {
|
|
182
|
+
return {
|
|
183
|
+
書きました: false,
|
|
184
|
+
止めた理由: problems,
|
|
185
|
+
note: '★戻していません。上を人が確かめてください。' +
|
|
186
|
+
'どうしても進めるときは force を付けてください(★おすすめしません)。' +
|
|
187
|
+
'詳しい中身は「伏せた版」と正本を並べて、ご自身の目で見てください。',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const dst = path.resolve(String(outPath || source || '').trim());
|
|
192
|
+
if (!dst) throw new Error('書き先が分かりません');
|
|
193
|
+
if (fs.existsSync(dst)) {
|
|
194
|
+
// ★上書きしない。元は控えへ寄せる(消さない)
|
|
195
|
+
const d = new Date(), p2 = (x) => String(x).padStart(2, '0');
|
|
196
|
+
const st = '' + d.getFullYear() + p2(d.getMonth() + 1) + p2(d.getDate()) + '-' + p2(d.getHours()) + p2(d.getMinutes());
|
|
197
|
+
try { fs.renameSync(dst, dst + '.bak-' + st); } catch (_) {}
|
|
198
|
+
}
|
|
199
|
+
fs.writeFileSync(dst, restored, 'utf8');
|
|
200
|
+
return {
|
|
201
|
+
書きました: true,
|
|
202
|
+
書き先: dst,
|
|
203
|
+
戻した印の数: Object.keys(map).length - missing.length,
|
|
204
|
+
注意: problems.length ? problems : undefined,
|
|
205
|
+
note: '★元のファイルは .bak として残しています。中身はAIに渡していません。ご自身で開いて確かめてください。',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* AIが書いた「伏せたままの文」を復元して、手元のファイルへ出す。
|
|
211
|
+
* ★これが「AIはコードしか見ない/人は正式な表示を読む」の実現形。
|
|
212
|
+
* チャット欄そのものは書き換えられない(MCPの外側)ので、**すぐ横のファイルに出す**。
|
|
213
|
+
*/
|
|
214
|
+
function render(FREE, maskId, text, outPath, dir) {
|
|
215
|
+
const base = path.resolve(dir || process.cwd());
|
|
216
|
+
const mapFile = path.join(base, MAPDIR, String(maskId).trim() + '.json');
|
|
217
|
+
if (!fs.existsSync(mapFile)) throw new Error('対応表が見つかりません: ' + mapFile);
|
|
218
|
+
const { map } = JSON.parse(fs.readFileSync(mapFile, 'utf8'));
|
|
219
|
+
const body = String(text || '');
|
|
220
|
+
const used = body.match(/\b(?:name|corp|office|shop|word|item)\d{3,6}\b/g) || [];
|
|
221
|
+
const unknown = [...new Set(used.filter((t) => !map[t]))];
|
|
222
|
+
const restored = Object.entries(map).reduce((s, [t, v]) => s.split(t).join(v), body);
|
|
223
|
+
|
|
224
|
+
const dst = path.resolve(outPath || path.join(base, '表示用_' + Date.now() + '.md'));
|
|
225
|
+
fs.writeFileSync(dst, restored, 'utf8');
|
|
226
|
+
return {
|
|
227
|
+
書き先: dst,
|
|
228
|
+
戻した印の数: used.length - unknown.length,
|
|
229
|
+
表に無い印: unknown.length ? unknown.length + ' 種(そのまま残っています)' : undefined,
|
|
230
|
+
note: '★このファイルを開いてください。中身はAIに渡していません。',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = { ALWAYS, scanAlways, LEVELS, getPolicy, setPolicy, maskFile, unmaskFile, render, MAPDIR };
|