llm-auto-humanize
public.icon
#UserScript
AIの薄表示 … 行を「人間の承認」で人間色(装飾なし)に戻す。
① 選択して承認: テキスト選択 → ポップアップの「承認」(スマホ本命・複数行可)
② 自動承認: 薄い行を編集してカーソル/フォーカスを離すと自動(PCの保険)
canonical = このページ。各 project の profile の code:script.js に import '/api/code/tkgshn-extension/llm-auto-humanize/script.js' を足して使う(単一ソース)。
薄表示の見た目は各 project の settings の .deco-( CSS(opacity 0.5)。
from tkgshn
code:script.js
import { patch } from '/api/code/tkgshn-extension/cosense-ws-bundle/script.js';
// ──────────────────────────────────────────────────────────────────────────
// llm-auto-humanize : AIの薄表示 … 行を「人間の承認」で人間色(装飾なし)に戻す
// ① 選択して承認 : テキスト選択 → PopupMenu「承認」 → 選択行を一括humanize(スマホ本命)
// ② 自動承認 : 薄い行を編集してカーソル/フォーカスを離すと自動humanize(PCの保険)
// canonical = tkgshn-extension/llm-auto-humanize(単一ソース。各 project の profile から import)
// ──────────────────────────────────────────────────────────────────────────
// 先頭 'に対応する '' の index を括弧の深さで求める。
// これで内側リンク link や後続 provenance … #hash を取り違えない。
const matchClose = (s) => {
const step = (depth, i) =>
i >= s.length ? -1
: si === '[' ? step(depth + 1, i + 1)
: si === ']' ? (depth === 1 ? i : step(depth - 1, i + 1))
: step(depth, i + 1);
return step(0, 0);
};
// 不変条件: 灰色(未承認)=装飾文字に '(' を含む / 白(承認済み)='(' を含まない素のテキスト。
// 承認しても icon は入れない(人間が承認した時点でその行は人間の本文=バッジ不要)。
// agent icon / 旧 tkgshn.icon 署名は除去する(承認時に AI アイコンも旧署名も消す)。
const stripIcons = (s) => s.replace(/\s*\(?:tkgshn|codex|claude code)\.icon\/g, '');
// 行末の provenance だけを本体と分離する。provenance = 末尾に連続する code span のうち
// #hash(session hash)か H:MM/HH:MM(時刻)の形のものだけ。本文中のインラインコード
// (例: 結果は 42)を誤って meta 扱いしないため、形を限定する。返す meta は前後空白を除去。
const META = /^(.*?)(\s*(?:(?:#[^]*|\d{1,2}:\d{2})`\s*)*)$/;
const splitMeta = (s) => { const mm = s.match(META); return [mm1, mm2.trim()]; };
const withMeta = (meta) => (meta === '' ? '' : ${meta}); // 常に単一スペースを前置
const trimEnd = (s) => s.replace(/\s+$/, '');
// 先頭の装飾トークン <装飾文字> <内容> を解析。装飾文字は ( * / - _ のみ(リンク Page Name
// は先頭語が装飾文字でないので null)。内側リンク x は matchClose が深さで content に保持。
const DECO = /^(*\/_-+$/;
const leadingDeco = (core) => {
const close = core0 === '[' ? matchClose(core) : -1;
const inner = close < 0 ? '' : core.slice(1, close);
const sp = inner.indexOf(' ');
const chars = sp < 0 ? '' : inner.slice(0, sp);
return sp < 0 || !DECO.test(chars)
? null
: { chars, content: inner.slice(sp + 1), rest: core.slice(close + 1) };
};
// 灰色を外す(行内の全 (… を対象。先頭・行中問わず溶かす。リンク Page・非灰色装飾 x は温存)。
// X→X、X→X(太字残す)、X→X(内側リンク保持)、( X→X(打ち消し包みも溶かす)、
// 複数兄弟 acb→acb(承認の意味論)。
const ungray = (s) => {
const open = s.indexOf('[');
return open < 0 ? s : (() => {
const close = matchClose(s.slice(open));
return close < 0 ? s : (() => {
const inner = s.slice(open + 1, open + close);
const after = s.slice(open + close + 1);
const d = leadingDeco([${inner}]);
const here = !d ? [${inner}] : (() => {
const remain = d.chars.replace(/\(/g, '');
const content = ungray(d.content);
return remain === '' ? content + d.rest : [${remain} ${content}]${d.rest};
})();
return s.slice(0, open) + here + ungray(after);
})();
})();
};
// 角括弧マークアップ(リンク・装飾)を全部外し可視テキストへ。打ち消し(却下)時の素テキスト化に使う。
// 角括弧以外(丸括弧・日本語)は不可侵。入れ子・複数リンクも再帰で平坦化。Page→Page、X→X。
const plain = (s) => {
const open = s.indexOf('[');
return open < 0 ? s : (() => {
const close = matchClose(s.slice(open));
return close < 0 ? s : (() => {
const inner = s.slice(open + 1, open + close);
const after = s.slice(open + close + 1);
const d = leadingDeco([${inner}]);
return s.slice(0, open) + plain(d ? d.content : inner) + plain(after);
})();
})();
};
// 灰色を付ける(装飾文字に '(' を足す)。X→X、X→X(既存装飾を保つ)。
// 空行・既に灰色の行は素通り(冪等)。
const gray = (core) => {
const d = leadingDeco(core);
return core === '' || (d && d.chars.includes('(')) ? core
: d ? [(${d.chars} ${d.content}]${d.rest}
: [( ${core}];
};
// 承認(灰色→白)。X→X、X→X、X→X、本文 #h→本文 #h。
// 打ち消しで包まれた灰色(灰色除去後に '-' が残る行)は「却下」=素テキストの打ち消し … にし、
// 内側のリンク・装飾も plain で全解除する(人間が打ち消した=inert 化)。元が灰色の行だけ却下扱い。
// AI アイコン・旧 tkgshn.icon は除去。署名は付けない。冪等。
const humanizeLine = (text) => {
const m = text.match(/^(\s*)(.*)$/);
const core, meta = splitMeta(stripIcons(m2));
const g = ungray(core);
const d = leadingDeco(g);
const struck = !!(core.includes('[(') && d && d.chars.includes('-'));
const body = trimEnd(struck ? [- ${plain(d.content)}]${d.rest} : g);
return body === '' ? m1 + meta : ${m[1]}${body}${withMeta(meta)};
};
// 承認対象=AIドラフト(灰色 or AIアイコン付き)かつ変化がある行のみ。人間の素の行は対象外。
const isDraft = (t) => t.includes('|| /\[(?:codex|claude code)\.icon\/.test(t);
const needsHumanize = (text) => isDraft(text) && humanizeLine(text) !== text;
// 逆操作(白→灰色)。署名・AIアイコンを除去し装飾に '(' を付与(装飾なしは … で包む)。
// hash は外側に残す。冪等:空行・既に灰色の行は触らない。humanize(grayify(x)) === x。
const grayifyLine = (text) => {
const m = text.match(/^(\s*)(.*)$/);
const core, meta = splitMeta(stripIcons(m2));
const inner = trimEnd(core);
const g = gray(inner);
return g === inner ? text : ${m[1]}${g}${withMeta(meta)};
};
const needsGrayify = (text) => grayifyLine(text) !== text;
// ── ① 選択して承認(PopupMenu)──────────────────────────────────────────
// onClick は選択テキスト(複数行は "\n" 連結のScrapbox記法)を受け取り、返り値で選択範囲を置換する。
// title が undefined を返すとボタンは出ない(型定義準拠)=承認対象が選択に含まれる時だけ「承認」を表示。
// 灰色 or AIアイコンを含む選択 → 「承認」(灰色→白・装飾の '(' とアイコンを除去、署名は付けない)。
// 各行 needsHumanize の行だけ変換し、人間の素の行は素通り(混在選択でも壊さない)。
scrapbox.PopupMenu.addButton({
title: (text) => (text.split('\n').some(needsHumanize) ? '承認' : undefined),
onClick: (text) => text.split('\n').map((l) => (needsHumanize(l) ? humanizeLine(l) : l)).join('\n'),
});
// 白を含む選択 → 「灰色に」(白→灰色・署名除去)。灰色行は素通り。
scrapbox.PopupMenu.addButton({
title: (text) => (text.split('\n').some(needsGrayify) ? '灰色に' : undefined),
onClick: (text) => text.split('\n').map((l) => (needsGrayify(l) ? grayifyLine(l) : l)).join('\n'),
});
// ── ② 自動承認(編集→離脱でhumanize、①の保険。PC/一部モバイル)──────────
const toLineId = (el) => (el ? el.id.replace(/^L/, '') : null);
const cursorLineId = () => toLineId(document.querySelector('.cursor-line'));
const onEditor = (t) => !!(t && (t.id === 'text-input' || (t.closest && t.closest('.editor'))));
// 人間がタイプした行 id を貯める。AIは websocket 直書きでこれらを発火しない=人間/AIを区別できる。
// モバイルIME(日本語)は input が飛ばないことがあるので beforeinput/compositionend も拾う。
const touched = new Set();
const remember = (e) => {
const id = onEditor(e.target) ? cursorLineId() : null;
const _ = id && touched.add(id);
};
'input', 'beforeinput', 'compositionend'.forEach((ev) => document.addEventListener(ev, remember, true));
// 触れた行のうち、カーソルが離れた行(force時は全部)を humanize。複数行も1patchで反映。
const flush = (force) => {
const cur = cursorLineId();
const lines = window.scrapbox?.Page?.lines ?? [];
const targets = ...touched.filter((id) => force || id !== cur);
targets.forEach((id) => touched.delete(id));
const fixes = new Map(
targets
.map((id) => lines.find((l) => l.id === id))
.filter((l) => l && needsHumanize(l.text))
.map((l) => l.id, humanizeLine(l.text))
);
return fixes.size === 0
? undefined
: patch(window.scrapbox.Project.name, window.scrapbox.Page.title, (ls) =>
ls.map((l) => {
const t = typeof l === 'string' ? l : l.text;
const id = typeof l === 'string' ? null : l.id;
return fixes.has(id) ? fixes.get(id) : t;
})
).catch((e) => console.error('llm-auto-humanize patch error', e));
};
setInterval(() => flush(false), 250);
// カーソルが別行へ移らずキーボードを閉じる/別要素へ移る操作(特にモバイル)を拾う。
document.addEventListener('focusout', (e) => {
const _ = onEditor(e.target) && setTimeout(() => flush(true), 50);
}, true);
console.log('llm-auto-humanize active (select-to-approve + auto)');