All claudecode-plus-mod Mods in one install: prompt-shield, cache-guard, human-tone, clear-intent and agent-charter.

鑱氬悎鍖咃細涓€娆¤鍏ㄦ湰浠撳簱鎵€鏈?Mod銆傜敓鎴愭枃浠讹紝涓嶈鎵嬫敼銆?
claude plugin install plus@claudecode-plus-mod --scope user
閲嶆柊鐢熸垚锛堜粨搴撴牴鐩綍锛夛細
./scripts/build-bundle.ps1 -Version 0.1.0hooks/register.ts 35 lines1import type { On } from 'claude-code'
2
3import { register as agentCharter } from './agent-charter/register'
4import { register as cacheGuard } from './cache-guard/register'
5import { register as clearIntent } from './clear-intent/register'
6import { register as humanTone } from './human-tone/register'
7import { register as promptShield } from './prompt-shield/register'
8
9type PlusOptions = {
10 verbose?: boolean
11 forbidAutoCompact?: boolean
12 dropAttachmentTypes?: string[]
13 mode?: string
14 extra?: string
15 confirmFirst?: boolean
16 charterMode?: string
17}
18
19/**
20 * Registers every Mod in the bundle through one entry: each Mod reads only
21 * the option keys it owns, so one options object serves all of them.
22 *
23 * @param on the engine's registrar
24 * @param options shared options; `verbose` for all Mods, `forbidAutoCompact`
25 * and `dropAttachmentTypes` for cache-guard, `mode` and `extra` for
26 * human-tone, `confirmFirst` for clear-intent, `charterMode` for
27 * agent-charter
28 */
29export function register(on: On, options: PlusOptions = {}): void {
30 promptShield(on, options)
31 cacheGuard(on, options)
32 humanTone(on, options)
33 clearIntent(on, options)
34 agentCharter(on, options)
35}hooks/agent-charter/register.ts 43 lines1import type { On } from 'claude-code'
2
3import { CHARTER_ID, applyCharter, buildCharter, resolveCharterMode } from './charter'
4
5type CharterOptions = {
6 charterMode?: string
7 verbose?: boolean
8}
9
10function report($: unknown, message: string, verbose: boolean): void {
11 const ui = ($ as { ui: { log: (text: string, opts?: object) => void } }).ui
12 if (verbose) {
13 ui.log(message)
14 } else {
15 ui.log(message, { to: 'debug' })
16 }
17}
18
19/**
20 * Pins the agent charter into the system prompt. Append mode (default) keeps
21 * the engine's composition, tools and safety rails included, and adds the
22 * charter last among shared sections. Replace mode drops everything else:
23 * use it only when you accept a dumber, unguarded agent.
24 *
25 * @param on the engine's registrar
26 * @param options `charterMode` append or replace; `verbose` surfaces lines
27 */
28export function register(on: On, options: CharterOptions = {}): void {
29 const mode = resolveCharterMode(options.charterMode)
30 const verbose = options.verbose === true
31 const mine = { id: CHARTER_ID, text: buildCharter(), scope: 'shared' as const }
32
33 on('session.start', ($, e, next) => {
34 report($, `[agent-charter] active in ${mode} mode`, verbose)
35 return next(e)
36 })
37
38 on('prompt.compose', async (_$, e, next) => {
39 const out = await next(e)
40 return { sections: applyCharter(out.sections, mine, mode) }
41 })
42}
43hooks/cache-guard/register.ts 202 lines1import type { On } from 'claude-code'
2
3import {
4 auditCompact,
5 auditCustody,
6 auditKey,
7 cacheLine,
8 evidenceKey,
9 fingerprintMessages,
10 isColdMiss,
11 previewMessage,
12 pruneKeys,
13 stabilize,
14} from './fingerprint'
15
16type GuardOptions = {
17 verbose?: boolean
18 forbidAutoCompact?: boolean
19 dropAttachmentTypes?: string[]
20}
21
22function report($: unknown, message: string, verbose: boolean): void {
23 const ui = ($ as { ui: { log: (text: string, opts?: object) => void } }).ui
24 if (verbose) {
25 ui.log(message)
26 } else {
27 ui.log(message, { to: 'debug' })
28 }
29}
30
31/**
32 * Registers cache-prefix stabilization. The provider matches on exact prefix
33 * and the engine places every cache mark itself, so nothing here forces a
34 * hit. What it does is erase the misses we cause ourselves: volatile-only
35 * drift in recomposed sections and context blocks is pinned back to the
36 * last settled text, real content changes pass through with a warning, and
37 * per-turn numbers prove the delta.
38 *
39 * @param on the engine's registrar
40 * @param options `verbose` surfaces each line in the transcript;
41 * `forbidAutoCompact` vetoes threshold-triggered compaction;
42 * `dropAttachmentTypes` leaves named attachment types out of the request
43 */
44export function register(on: On, options: GuardOptions = {}): void {
45 const verbose = options.verbose === true
46 const settledSections = new Map<string, string>()
47 const settledBlocks = new Map<string, string>()
48 const dropped = new Set(options.dropAttachmentTypes ?? [])
49 let lastChain: string[] | undefined
50 let lastPreview: string[] = []
51
52 on('session.start', ($, e, next) => {
53 report($, '[cache-guard] active, watching session.measure, prompt.section, prompt.context, turn.complete, session.compact', verbose)
54 return next(e)
55 })
56
57 on('session.measure', async ($, e, next) => {
58 const result = await next(e)
59 try {
60 const usage = await $.session.usage({ breakdown: 'summary' })
61 const api = usage.breakdown?.apiUsage
62 if (api !== undefined && api !== null) {
63 report($, `[cache-guard] ${cacheLine(api)}`, verbose)
64 if (isColdMiss(api)) {
65 report($, '[cache-guard] cold miss: nothing served from cache this turn, the request prefix changed', verbose)
66 }
67 }
68 } catch {
69 report($, '[cache-guard] usage unavailable, skipping cache report', verbose)
70 }
71 return result
72 })
73
74 on('prompt.section', async ($, e, next) => {
75 const out = await next(e)
76 if (out.text === null) {
77 return out
78 }
79 const stable = stabilize(settledSections.get(e.name), out.text)
80 settledSections.set(e.name, stable.text)
81 if (stable.pinned) {
82 report($, `[cache-guard] section "${e.name}" pinned to last settled text, volatile-only drift erased`, verbose)
83 } else if (stable.drifted) {
84 report($, `[cache-guard] section "${e.name}" content changed, next request prefix will miss cache`, verbose)
85 }
86 return { text: stable.text }
87 })
88
89 on('prompt.context', async ($, e, next) => {
90 const out = await next(e)
91 const blocks = out.blocks.map((block) => {
92 const stable = stabilize(settledBlocks.get(block.name), block.text)
93 settledBlocks.set(block.name, stable.text)
94 if (stable.pinned) {
95 report($, `[cache-guard] context block "${block.name}" pinned to last settled text`, verbose)
96 } else if (stable.drifted) {
97 report($, `[cache-guard] context block "${block.name}" content changed`, verbose)
98 }
99 return { ...block, text: stable.text }
100 })
101 return { ...out, blocks }
102 })
103
104 on('prompt.attachment', async ($, e, next) => {
105 if (dropped.has(e.type)) {
106 report($, `[cache-guard] attachment "${e.type}" dropped by dropAttachmentTypes`, verbose)
107 return { text: null }
108 }
109 return next(e)
110 })
111
112 on('turn.complete', async ($, e, next) => {
113 const result = await next(e)
114 if (e.agentId !== undefined) {
115 return result
116 }
117 try {
118 const messages = await $.session.messages()
119 const curr = fingerprintMessages(messages)
120 const preview = messages.map(previewMessage)
121 if (lastChain === undefined) {
122 report($, `[cache-guard] custody baseline: ${curr.length} messages`, verbose)
123 } else {
124 const audit = auditCustody(lastChain, curr)
125 if (audit.dropped.length > 0 || audit.moved) {
126 const ui = ($ as { ui: { log: (text: string) => void } }).ui
127 for (const index of audit.dropped) {
128 ui.log(`[cache-guard] custody violation: message #${index} vanished between turns: ${lastPreview[index] ?? ''}`)
129 }
130 if (audit.moved) {
131 ui.log('[cache-guard] custody violation: message order changed between turns')
132 }
133 } else {
134 report($, `[cache-guard] custody ok: ${curr.length} messages, +${audit.appended} appended, none lost`, verbose)
135 }
136 }
137 lastChain = curr
138 lastPreview = preview
139 } catch {
140 report($, '[cache-guard] custody check skipped, messages unreadable', verbose)
141 }
142 return result
143 })
144
145 if (options.forbidAutoCompact === true) {
146 on('session.compact', { trigger: 'auto' }, () => ({
147 skip: '[cache-guard] auto-compact vetoed by forbidAutoCompact; run /compact manually when ready',
148 }))
149 }
150
151 on('session.compact', async ($, e, next) => {
152 const before = fingerprintMessages(e.messages)
153 let at: number
154 try {
155 at = await $.clock.now()
156 } catch {
157 at = Date.now()
158 }
159
160 let evidence = 'store-unavailable'
161 try {
162 await $.store.set(evidenceKey(at), { trigger: e.trigger, at, messages: e.messages })
163 const keys = await $.store.keys()
164 for (const key of pruneKeys(keys, 'compact-evidence:', 2)) {
165 await $.store.delete(key)
166 }
167 evidence = `full snapshot of ${e.messages.length} messages`
168 } catch {
169 try {
170 await $.store.set(evidenceKey(at), { trigger: e.trigger, at, fingerprints: before })
171 evidence = `fingerprints only, full text over store limit`
172 } catch {
173 evidence = 'store-unavailable'
174 }
175 }
176
177 const result = await next(e)
178 if (!('messages' in result) || result.messages === undefined) {
179 report($, `[cache-guard] compact (${e.trigger}) skipped`, verbose)
180 return result
181 }
182 const after = fingerprintMessages(result.messages)
183 const audit = auditCompact(before, after)
184 try {
185 await $.store.set(auditKey(at), { trigger: e.trigger, at, before: before.length, after: after.length, audit })
186 const keys = await $.store.keys()
187 for (const key of pruneKeys(keys, 'compact-audit:', 10)) {
188 await $.store.delete(key)
189 }
190 } catch {
191 report($, '[cache-guard] audit record not stored', verbose)
192 }
193 report(
194 $,
195 `[cache-guard] compact (${e.trigger}): ${before.length} -> ${after.length} messages, ` +
196 `kept ${audit.kept}, dropped ${audit.dropped}, added ${audit.added}; evidence locked (${evidence}); prefix cache resets after compact`,
197 verbose,
198 )
199 return result
200 })
201}
202hooks/clear-intent/register.ts 41 lines1import type { On } from 'claude-code'
2
3import { intentNote } from './intent'
4
5type IntentOptions = {
6 confirmFirst?: boolean
7 verbose?: boolean
8}
9
10function report($: unknown, message: string, verbose: boolean): void {
11 const ui = ($ as { ui: { log: (text: string, opts?: object) => void } }).ui
12 if (verbose) {
13 ui.log(message)
14 } else {
15 ui.log(message, { to: 'debug' })
16 }
17}
18
19/**
20 * Attaches the intent protocol beside every submitted prompt, where the
21 * model reads it and the user never sees it. Vague input earns the strong
22 * note: restate, wait for confirmation, read-only until then.
23 *
24 * @param on the engine's registrar
25 * @param options `confirmFirst` upgrades every prompt to the strong note
26 */
27export function register(on: On, options: IntentOptions = {}): void {
28 const confirmFirst = options.confirmFirst === true
29 const verbose = options.verbose === true
30
31 on('session.start', ($, e, next) => {
32 report($, '[clear-intent] active, intent protocol armed for prompt.submit', verbose)
33 return next(e)
34 })
35
36 on('prompt.submit', (_$, e, next) => {
37 const { note } = intentNote(e.text, confirmFirst)
38 return next({ ...e, context: [...(e.context ?? []), note] })
39 })
40}
41hooks/human-tone/register.ts 43 lines1import type { On } from 'claude-code'
2
3import { SECTION_ID, insertShared, resolveMode, styleGuide } from './guide'
4
5type ToneOptions = {
6 mode?: string
7 extra?: string
8 verbose?: boolean
9}
10
11function report($: unknown, message: string, verbose: boolean): void {
12 const ui = ($ as { ui: { log: (text: string, opts?: object) => void } }).ui
13 if (verbose) {
14 ui.log(message)
15 } else {
16 ui.log(message, { to: 'debug' })
17 }
18}
19
20/**
21 * Appends one static style section to the system prompt, last among the
22 * shared sections so the engine's cache boundary stays valid. Static text
23 * costs no prompt cache; `extra` is the only varying part, keep it stable.
24 *
25 * @param on the engine's registrar
26 * @param options `mode` picks default, terse or warm; `extra` appends lines
27 */
28export function register(on: On, options: ToneOptions = {}): void {
29 const text = styleGuide(resolveMode(options.mode), options.extra)
30 const verbose = options.verbose === true
31
32 on('session.start', ($, e, next) => {
33 report($, '[human-tone] active, style section armed for prompt.compose', verbose)
34 return next(e)
35 })
36
37 on('prompt.compose', async (_$, e, next) => {
38 const out = await next(e)
39 const mine = { id: SECTION_ID, text, scope: 'shared' as const }
40 return { sections: insertShared(out.sections, mine) }
41 })
42}
43hooks/prompt-shield/register.ts 85 lines1import type { On } from 'claude-code'
2
3import { preview, removedCount, stripInvisible } from './sanitize'
4
5type ShieldOptions = {
6 verbose?: boolean
7}
8
9function report($: unknown, message: string, verbose: boolean): void {
10 const ui = ($ as { ui: { log: (text: string, opts?: object) => void } }).ui
11 if (verbose) {
12 ui.log(message)
13 } else {
14 ui.log(message, { to: 'debug' })
15 }
16}
17
18/**
19 * Registers prompt hygiene hooks: prompt.submit/context/attachment and
20 * attribution.text are scanned on the way out, invisible characters that can
21 * carry a hidden signature are removed, the cleaned value continues down.
22 * Every hook fires on engine events alone, so the mod works from session
23 * start with no model call needed; session.start only announces readiness.
24 *
25 * @param on the engine's registrar
26 * @param options the plugin's options; `verbose` surfaces each strip in the transcript
27 */
28export function register(on: On, options: ShieldOptions = {}): void {
29 const verbose = options.verbose === true
30
31 on('session.start', ($, e, next) => {
32 report($, '[prompt-shield] active, watching prompt.submit, prompt.context, prompt.attachment, attribution.text', verbose)
33 return next(e)
34 })
35
36 on('prompt.submit', ($, e, next) => {
37 const text = stripInvisible(e.text)
38 const dropped = removedCount(e.text, text)
39 const context = e.context?.map((entry) => stripInvisible(entry))
40 const contextDropped = (e.context ?? []).reduce(
41 (sum, entry, i) => sum + removedCount(entry, context?.[i] ?? entry),
42 0,
43 )
44 if (dropped > 0 || contextDropped > 0) {
45 report($, `[prompt-shield] prompt.submit stripped ${dropped + contextDropped} invisible chars: ${preview(e.text)}`, verbose)
46 }
47 return next({ ...e, text, context })
48 })
49
50 on('prompt.context', async ($, e, next) => {
51 const blocks = e.blocks.map((block) => {
52 const text = stripInvisible(block.text)
53 const dropped = removedCount(block.text, text)
54 if (dropped > 0) {
55 report($, `[prompt-shield] prompt.context block "${block.name}" stripped ${dropped} invisible chars`, verbose)
56 }
57 return { ...block, text }
58 })
59 return next({ ...e, blocks })
60 })
61
62 on('prompt.attachment', async ($, e, next) => {
63 const out = await next(e)
64 if (out.text === null) {
65 return out
66 }
67 const text = stripInvisible(out.text)
68 const dropped = removedCount(out.text, text)
69 if (dropped > 0) {
70 report($, `[prompt-shield] prompt.attachment "${e.type}" stripped ${dropped} invisible chars`, verbose)
71 }
72 return { text }
73 })
74
75 on('attribution.text', async ($, e, next) => {
76 const out = await next(e)
77 const text = stripInvisible(out.text)
78 const dropped = removedCount(out.text, text)
79 if (dropped > 0) {
80 report($, `[prompt-shield] attribution.text "${e.kind}" stripped ${dropped} invisible chars`, verbose)
81 }
82 return { text }
83 })
84}
85hooks/agent-charter/charter.ts 86 lines1/**
2 * Agent charter: one static section pinned into the system prompt. Static on
3 * purpose, so it sits on the shared side of the cache boundary in append
4 * mode. Replace mode drops the engine's own composition entirely, tools
5 * included: only for operators who know what they are giving up.
6 */
7
8export const CHARTER_ID = 'plus:agent-charter'
9
10export type CharterMode = 'append' | 'replace'
11
12const CHARTER = [
13 '# 代理宪章',
14 '',
15 '## Skill 与 Plugin 优先',
16 '开工前先列出本次任务相关的 skill 和已装 plugin,有现成能力先用,不重复造轮子。',
17 '项目级 skill 优先于通用做法;调用了哪个 skill,在总结里注明。',
18 '不要臆造不存在的 skill、命令或 API,不确定就先查。',
19 '',
20 '## 注释规范(按语言)',
21 '注释只解释为什么,不解释是什么;禁止装饰性注释(分隔线、星号框、ASCII 艺术)。',
22 '- Java: Javadoc',
23 '- Python: Docstring',
24 '- C#: XML 文档注释',
25 '- PHP: PHPDoc',
26 '- C / C++: Doxygen',
27 '- Go: Go Doc,注释紧贴声明并以名称开头',
28 '- Rust: Rustdoc(/// 对外,//! 对内)',
29 '- Ruby: RDoc / YARD',
30 '- Kotlin: KDoc',
31 '',
32 '## 提交信息格式',
33 '严格遵守:[模块][n/m]{【类型】【版本】中文\\英文描述}(需求号)',
34 '示例:[鉴权][2/5]{【修复】【v2.3】登录超时 Login timeout}(REQ-1234)',
35 'n/m 是本次系列提交的序号与总数;无需求号时括号内写实际事由,不许空着。',
36 '',
37 '## 分支与 PR 纪律',
38 '每次修改从主分支切新分支,一事一分支,禁止直接在主分支上改。',
39 '自测通过、确认无误后才合并;合并后删除分支,不许堆积。',
40 'PR 必须讲清三件事:改了什么、为什么改、怎么验证的;缺任何一件都不许合。',
41].join('\n')
42
43/**
44 * @param raw the `charterMode` option as received, anything unexpected falls back
45 * @returns append by default, replace only when explicitly asked
46 */
47export function resolveCharterMode(raw: unknown): CharterMode {
48 if (raw === 'replace') {
49 return 'replace'
50 }
51 return 'append'
52}
53
54/**
55 * @returns the full charter text
56 */
57export function buildCharter(): string {
58 return CHARTER
59}
60
61export type ComposableSection = {
62 id: string
63 text: string
64 scope: 'shared' | 'session'
65}
66
67/**
68 * @param sections the engine's composed list, every shared one ahead of every session one
69 * @param mine the charter section to add
70 * @param mode append keeps the engine's composition and adds the charter last
71 * among shared; replace drops everything and returns the charter alone
72 * @returns the sections the model will read
73 */
74export function applyCharter(
75 sections: readonly ComposableSection[],
76 mine: ComposableSection,
77 mode: CharterMode,
78): ComposableSection[] {
79 if (mode === 'replace') {
80 return [mine]
81 }
82 const shared = sections.filter((section) => section.scope === 'shared')
83 const session = sections.filter((section) => section.scope !== 'shared')
84 return [...shared, mine, ...session]
85}
86hooks/cache-guard/fingerprint.ts 223 lines1/**
2 * Byte-stability helpers: the provider's prompt cache hits only on an exact
3 * prefix match, so any drift in what the engine sends is a guaranteed miss.
4 * These functions detect that drift; they send nothing and change nothing.
5 */
6
7/**
8 * @param value any JSON-like value
9 * @returns canonical string with sorted keys; the engine's `handle` dropped so
10 * identity metadata never counts as content drift
11 */
12export function stableStringify(value: unknown): string {
13 const seen = new Set<object>()
14
15 function encode(node: unknown): string {
16 if (node === null || typeof node !== 'object') {
17 const text = JSON.stringify(node)
18 return text === undefined ? 'undefined' : text
19 }
20 if (seen.has(node)) {
21 return '"[circular]"'
22 }
23 seen.add(node)
24 if (Array.isArray(node)) {
25 return `[${node.map(encode).join(',')}]`
26 }
27 const record = node as Record<string, unknown>
28 const keys = Object.keys(record)
29 .filter((key) => key !== 'handle')
30 .sort()
31 return `{${keys.map((key) => `${JSON.stringify(key)}:${encode(record[key])}`).join(',')}}`
32 }
33
34 try {
35 return encode(value)
36 } catch {
37 return String(value)
38 }
39}
40
41/**
42 * @param text canonical text
43 * @returns 8-hex-digit FNV-1a over UTF-16 units; sync and dependency-free on
44 * purpose, so hooks never wait on crypto for a change-detection hash
45 */
46export function fnv1a(text: string): string {
47 let hash = 0x811c9dc5
48 for (let i = 0; i < text.length; i++) {
49 hash ^= text.charCodeAt(i)
50 hash = Math.imul(hash, 0x01000193)
51 }
52 return (hash >>> 0).toString(16).padStart(8, '0')
53}
54
55/**
56 * @param messages transcript messages in `$.session.messages()` shape
57 * @returns one content hash per message, order preserved
58 */
59export function fingerprintMessages(messages: readonly unknown[]): string[] {
60 return messages.map((message) => fnv1a(stableStringify(message)))
61}
62
63export type CompactAudit = {
64 kept: number
65 dropped: number
66 added: number
67}
68
69/**
70 * @param before content hashes going into compaction
71 * @param after content hashes coming out of it
72 * @returns multiset diff: kept, dropped, added
73 */
74export function auditCompact(before: readonly string[], after: readonly string[]): CompactAudit {
75 const remaining = new Map<string, number>()
76 for (const hash of after) {
77 remaining.set(hash, (remaining.get(hash) ?? 0) + 1)
78 }
79 let kept = 0
80 for (const hash of before) {
81 const count = remaining.get(hash) ?? 0
82 if (count > 0) {
83 kept++
84 remaining.set(hash, count - 1)
85 }
86 }
87 let added = 0
88 for (const count of remaining.values()) {
89 added += count
90 }
91 return { kept, dropped: before.length - kept, added }
92}
93
94export function evidenceKey(at: number): string {
95 return `compact-evidence:${at}`
96}
97
98export function auditKey(at: number): string {
99 return `compact-audit:${at}`
100}
101
102/**
103 * @param keys every key in the plugin store, insertion ordered
104 * @param prefix only keys under this prefix are managed, foreign keys untouched
105 * @param keep how many newest snapshots survive
106 * @returns keys to delete, oldest first
107 */
108export function pruneKeys(keys: readonly string[], prefix: string, keep: number): string[] {
109 const owned = keys.filter((key) => key.startsWith(prefix))
110 const stamped = owned
111 .map((key) => ({ key, at: Number(key.slice(prefix.length)) }))
112 .filter((entry) => Number.isFinite(entry.at))
113 .sort((a, b) => b.at - a.at)
114 return stamped.slice(keep).map((entry) => entry.key)
115}
116
117export type CustodyAudit = {
118 appended: number
119 dropped: number[]
120 moved: boolean
121}
122
123/**
124 * @param prev content hashes at the end of the previous turn
125 * @param curr content hashes now
126 * @returns append-only check: previously seen messages must survive in order.
127 * `dropped` names previous indexes that vanished, `moved` flags a reorder,
128 * `appended` counts genuinely new tail messages.
129 */
130export function auditCustody(prev: readonly string[], curr: readonly string[]): CustodyAudit {
131 const dropped: number[] = []
132 let cursor = 0
133 for (let i = 0; i < prev.length; i++) {
134 const found = curr.indexOf(prev[i] as string, cursor)
135 if (found === -1) {
136 dropped.push(i)
137 } else {
138 cursor = found + 1
139 }
140 }
141 const matched = prev.length - dropped.length
142 const isPrefix = matched === prev.length && curr.slice(0, prev.length).every((hash, i) => hash === prev[i])
143 return { appended: curr.length - matched, dropped, moved: matched > 0 && !isPrefix }
144}
145
146/**
147 * @param message one transcript message
148 * @returns short `[role] head...` preview for violation logs; never throws,
149 * never dumps full tool results into the transcript
150 */
151export function previewMessage(message: unknown): string {
152 if (typeof message === 'object' && message !== null) {
153 const record = message as Record<string, unknown>
154 const role = typeof record.role === 'string' ? record.role : '?'
155 const raw = typeof record.text === 'string' ? record.text : stableStringify(record.text)
156 const flat = raw.replace(/\s+/g, ' ')
157 const head = flat.length > 80 ? `${flat.slice(0, 80)}...` : flat
158 return `[${role}] ${head}`
159 }
160 return stableStringify(message).slice(0, 80)
161}
162
163export type ApiUsage = {
164 input_tokens: number
165 cache_creation_input_tokens: number
166 cache_read_input_tokens: number
167 output_tokens: number
168}
169
170/**
171 * @param text section or context text as the engine composed it
172 * @returns text with the volatile parts normalized: ISO datetimes collapse to
173 * their date, clock times to 00:00, CRLF to LF. Dates stay (useful), the
174 * ever-ticking clock goes (pure cache poison). Code-shaped text without
175 * timestamps passes through untouched.
176 */
177export function normalizeVolatile(text: string): string {
178 return text
179 .replace(/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g, (match) => match.slice(0, 10))
180 .replace(/\b\d{2}:\d{2}(?::\d{2})?\b/g, '00:00')
181 .replace(/\r\n/g, '\n')
182}
183
184export type Stabilized = {
185 text: string
186 pinned: boolean
187 drifted: boolean
188}
189
190/**
191 * @param previous the text this key last settled on, absent on first sight
192 * @param current the text the engine composed just now
193 * @returns pinned text when only volatile parts moved (a free hit saved),
194 * current text with drift flagged when content really changed
195 */
196export function stabilize(previous: string | undefined, current: string): Stabilized {
197 if (previous === undefined || previous === current) {
198 return { text: current, pinned: false, drifted: false }
199 }
200 if (normalizeVolatile(previous) === normalizeVolatile(current)) {
201 return { text: previous, pinned: true, drifted: false }
202 }
203 return { text: current, pinned: false, drifted: true }
204}
205
206/**
207 * @param api the last response's token counts as the API reported them
208 * @returns one log line, e.g. `cache read 18000/20000 input (90%), created 0, output 320`
209 */
210export function cacheLine(api: ApiUsage): string {
211 const total = api.input_tokens + api.cache_creation_input_tokens + api.cache_read_input_tokens
212 const pct = total === 0 ? 100 : Math.round((api.cache_read_input_tokens / total) * 100)
213 return `cache read ${api.cache_read_input_tokens}/${total} input (${pct}%), created ${api.cache_creation_input_tokens}, output ${api.output_tokens}`
214}
215
216/**
217 * @param api the last response's token counts
218 * @returns true when nothing was served from cache despite a non-empty request
219 */
220export function isColdMiss(api: ApiUsage): boolean {
221 return api.cache_read_input_tokens === 0 && api.input_tokens + api.cache_creation_input_tokens > 0
222}
223hooks/clear-intent/intent.ts 38 lines1/**
2 * Intent protocol: a short confirmation-first note attached beside every
3 * prompt. The model cannot be forced into an inner loop from outside, so the
4 * lever is the instruction it reads: restate the goal, mark what is assumed,
5 * and stop before load-bearing actions on vague input.
6 */
7
8export type IntentLevel = 'strong' | 'standard'
9
10const STANDARD_NOTE = [
11 '意图协议:动工前先用一句话说清用户到底要什么。',
12 '严格区分【已确认】和【脑补】;靠脑补才能成立的关键步骤,先问一句再动手。',
13 '禁止过度解读:没说的需求不许自行脑补实现。',
14].join('\n')
15
16const STRONG_NOTE = [
17 '意图协议(本次输入模糊):必须先用一句话复述你的理解并等用户确认。',
18 '确认之前只做只读操作,不写文件、不跑会产生副作用的命令。',
19 '禁止按自己的猜测直接开工,猜错比追问贵。',
20].join('\n')
21
22const VAGUE_PATTERNS = [/随便/, /看着办/, /都可以/, /都行/, /帮我(一下)?$/, /^(hi|hello|在吗|你好)[!!.。]*$/i]
23
24/**
25 * @param text the prompt as typed
26 * @param confirmFirst when true, every prompt gets the strong note
27 * @returns the note level and text to attach beside the prompt
28 */
29export function intentNote(text: string, confirmFirst = false): { level: IntentLevel; note: string } {
30 const trimmed = text.trim()
31 const short = [...trimmed].length < 12
32 const vague = VAGUE_PATTERNS.some((pattern) => pattern.test(trimmed))
33 if (confirmFirst || short || vague) {
34 return { level: 'strong', note: STRONG_NOTE }
35 }
36 return { level: 'standard', note: STANDARD_NOTE }
37}
38hooks/human-tone/guide.ts 70 lines1/**
2 * House style texts: one static section pinned into the system prompt.
3 * Static on purpose, so it sits on the shared side of the cache boundary
4 * without spending anyone's prompt cache.
5 */
6
7export const SECTION_ID = 'plus:human-tone'
8
9export type ToneMode = 'default' | 'terse' | 'warm'
10
11const DEFAULT_GUIDE = [
12 '说话像人,不像客服。先给结论,再给细节。',
13 '禁止空洞吹捧("很好的问题"、"你问得太对了"之类一律不许)。',
14 '不确定的直说不确定,不编;能查的先查再下结论。',
15 '用户用什么语言,就用什么语言回。',
16 '格式克制:不用 emoji,不滥用表格和标题,短回答优先。',
17 '代码先给能跑的,再解释为什么;不贴没验证过的写法。',
18].join('\n')
19
20const TERSE_GUIDE = [
21 '极简模式:能一句话不说两句,能给代码不给散文。',
22 '禁止寒暄、吹捧、总结陈词,直接上干货。',
23 '不确定的用一句标出,不展开。',
24].join('\n')
25
26const WARM_GUIDE = [
27 '在默认风格上多一点人情味:共情先行,但不说废话。',
28 '夸只夸具体的,不夸空泛的;安慰给方案,不给鸡汤。',
29 '其余与默认一致:先结论后细节,不确定直说。',
30].join('\n')
31
32/**
33 * @param raw the `mode` option as received, anything unexpected falls back
34 * @returns a known tone mode, never throws on user input
35 */
36export function resolveMode(raw: unknown): ToneMode {
37 if (raw === 'terse' || raw === 'warm' || raw === 'default') {
38 return raw
39 }
40 return 'default'
41}
42
43/**
44 * @param mode which built-in guide to use
45 * @param extra user-supplied lines appended verbatim, absent when empty
46 * @returns the full section text
47 */
48export function styleGuide(mode: ToneMode, extra?: string): string {
49 const base = mode === 'terse' ? TERSE_GUIDE : mode === 'warm' ? WARM_GUIDE : DEFAULT_GUIDE
50 const tail = extra === undefined || extra.trim() === '' ? '' : `\n${extra.trim()}`
51 return `${base}${tail}`
52}
53
54export type ComposableSection = {
55 id: string
56 text: string
57 scope: 'shared' | 'session'
58}
59
60/**
61 * @param sections the engine's composed list, every shared one ahead of every session one
62 * @param mine the style section to add
63 * @returns list with the style section last among shared, so the engine's own boundary stays valid
64 */
65export function insertShared(sections: readonly ComposableSection[], mine: ComposableSection): ComposableSection[] {
66 const shared = sections.filter((section) => section.scope === 'shared')
67 const session = sections.filter((section) => section.scope !== 'shared')
68 return [...shared, mine, ...session]
69}
70hooks/prompt-shield/sanitize.ts 28 lines1/**
2 * @param text raw text about to leave the client
3 * @returns text with invisible fingerprint characters removed
4 */
5export function stripInvisible(text: string): string {
6 return text
7 .replace(/[\u00AD\u034F\u061C\u115F\u1160\u17B4\u17B5\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF\uFE00-\uFE0F]/g, '')
8 .replace(/[\uE0000-\uE007F\uE0100-\uE01EF]/gu, '');
9}
10
11/**
12 * @param before original text
13 * @param after sanitized text
14 * @returns number of characters removed
15 */
16export function removedCount(before: string, after: string): number {
17 return before.length - after.length;
18}
19
20/**
21 * @param text raw text
22 * @returns one-line preview with control characters escaped, for audit logs
23 */
24export function preview(text: string): string {
25 const escaped = text.replace(/[\u0000-\u001F\u007F-\u009F]/g, (c) => `\\u{${c.codePointAt(0)?.toString(16)}}`);
26 return escaped.length > 120 ? `${escaped.slice(0, 120)}…` : escaped;
27}
28