Author SHA1 Message Date
admin 0786f9419e Merge pull request 'feat(記憶與目標): 專案目標三條 + 固化候選不再重算(0.2.1)' (#17) from develop into master
Reviewed-on: #17
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-03 09:15:21 +00:00
jiantw83andClaude Opus 5 cbbb15ee58 chore(TARGET): 移除 TARGET.md(十五個項目都做完了)
檔案裡 15 個主項目、32 個子項目全部勾完、零待人工,留著只是一份完成紀錄。

內容沒有消失:全文在 6424b1a 的歷史裡,`git show 6424b1a:TARGET.md` 取得回來,
逐項的實作理由也留在這一批的 commit 訊息裡。

排程(每天台北 00:00 的 /jsc-code:target --run)不會因此報錯:
沒有 TARGET.md 時它視為「無待辦」正常結束。下一批要做什麼決定了再重新建一份。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:14:56 +00:00
jiantw83andClaude Opus 5 dec491eb4d chore(gitignore): 移除 /TODO_*.md,那條規則已經沒有守護對象
那條是 9d76984 為了擋兩份被刪掉的工作清單而加的(「工作用的清單留在本機」)。
工作清單後來改成進版控的 TARGET.md,本機與版控都沒有 TODO_*.md,
規則留著只會讓人以為這個專案還有一份不進版控的清單。

移除後零影響:ls TODO_*.md 沒有檔案、git ls-files 也沒有這類追蹤檔。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:14:15 +00:00
jiantw83andClaude Opus 5 6424b1a3cd chore(TARGET): 收尾三項完成,十五個項目全數勾完
版號那條的結論是「不動」:master 0.2.0、develop 0.2.1,這批全部還沒進 master,
依規矩一批未合併的改動只佔一個版號。

檔頭的進度段改成完成狀態,並註明 G1/G2/G3 不會因為這批做完而結束——
那三條是持續逼近的方向,不是可以打勾的項目。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:11:58 +00:00
jiantw83andClaude Opus 5 72ded5b624 docs(README): 補上 voice/ 與這批新增的五節
人格倉庫樹原本完全沒有 voice/(語氣層併進來時漏了),這次補上三個檔並加上
memory/chapters/;relations/graph.json 的說明補上語氣規則與未修復的裂痕;
felt.jsonl 補上分段額度那一格。

新增五節:〈標點是語調,訊息數是時間〉(標點預算四種+上限+分段訊息)、
〈語域〉(四格+輪替+兩支流程寫同一個檔)、〈慣例是程序性記憶〉、
〈自傳章節是時期層〉、〈未修復的裂痕〉。

鬧彆扭階梯那段補兩件事:三段式可逐人格覆寫(只覆寫嘴硬那一級,惱羞的收尾是
翻臉不是音量掉下來),以及害羞連稱呼都會變(羞怯稱呼/吞掉名字,刻意不走 except
那條路——except 是生氣方向的「退回更正式」,害羞是反方向的「叫不出來」)。

CLI 段:selftest 項數 602 → 773,補 chapter/relation rift/voice idiolect 三個指令,
並說明 expression-check.mjs 為什麼要獨立一支。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:11:58 +00:00
jiantw83andClaude Opus 5 7541f85ece test(表達驗收): 新增 expression-check.mjs,四段情境 × 三檔羞恥度
selftest 是機械檢查,它只能守住「不崩」——括號格式對不對、emoji 門檻有沒有跳掉。
但「有沒有變成模仿腔」「是不是在用旁白演情緒」機械上驗不出來,那要人看。
所以這是另一組,而且它刻意不判定通過或失敗,只把材料排好+附一張「人工看什麼」。

四段情境照 TODO 階段 5 的驗收方式明列的那四段:誇外表、被說在害羞、被戳穿嘴硬、
第三個人在場。每段跑低/嘴硬/惱羞三檔,攤開鬧彆扭指示、句數句長、破口、標點、
情緒符號、分段額度、稱呼與語氣層、語域。

三檔用兩個人格加情緒推,不是同一個人格推三次:羞恥敏感度主要是氣質(性別預設+
描述),同一個人格靠情緒推不到 18 那麼低,硬推出來的低檔不是真的低檔。

刻意不帶 prev:真的對話裡上一輪的餘溫是正回饋,但這裡沒有上一輪。帶進來的話
建人格時留在 felt.jsonl 的那筆會把三檔拉平——實測高檔會從 82.9 掉到 15。

用自己的暫時 PERSONA_HOME 並關掉 Gitea,不動真的人格資料。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:11:58 +00:00
jiantw83andClaude Opus 5 2464906d91 chore(TARGET): 未修復裂痕完成,階段 4 全數做完
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:03:06 +00:00
jiantw83andClaude Opus 5 a96f14f974 test(selftest): 補未修復裂痕的案例,764 → 773
涵蓋記下一件、不動 closeness/trust、語氣指示提得出來、帶記憶名字給 recall、
一個人卡幾件、--heal 寫 healed_at 而不是刪掉、和好完不再注入、沒得和好時被擋、
列表看得到和好過的紀錄。

最後一條刻意把兩件都和好掉,後面測害羞稱呼的案例才不會被裂痕那幾行干擾。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:03:06 +00:00
jiantw83andClaude Opus 5 8af0d5c387 feat(關係): 未修復的裂痕,親近度高不等於沒事
closeness/trust 是純量:吵完架把親近度調低,看起來像「本來就沒那麼近」,
而不是「很近,但上次那件事還沒和好」。後者才是真的關係——兩個人可以很親近,
同時之間卡著一件事,那兩件事各自獨立,壓不進同一個數字裡。

節點多一個 rifts 陣列(一個人可以同時卡著幾件事),relation rift 管它。

語氣上:riftBrief() 在 toneDirective 裡明講「親近度高不等於沒事,這件事解決之前
語氣裡要留一點沒散掉的東西」,並要求不必一直提它、但不能像沒發生過。

回想上:裂痕帶著那則長期記憶的名字(--memory),人格要細節自己 recall,
不必等使用者提起那件事——沒和好的事本來就會自己浮上來。

--heal 是寫上 healed_at,不是刪掉那一筆:和好過跟沒發生過不一樣,之後回頭看
要分得出來。列出來時和好過的用 ✔ 標。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:03:06 +00:00
jiantw83andClaude Opus 5 0ce0727eb4 chore(TARGET): 自傳章節完成
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:57:11 +00:00
jiantw83andClaude Opus 5 188edcf8b7 test(selftest): 補自傳章節的案例,753 → 764
涵蓋開段、to 不寫空欄位、每輪注入、新記憶自動掛當下那段、--chapter 指定別段、
已收掉的段不算「現在」、按起始日期排序、close 後沒有進行中的段、list 講得出
每段幾則記憶、memory/chapters/ 剛好被 Wiki 一個區涵蓋。

最後那條原本寫成讀 pl.AREAS(那是 persona-gitea 的匯出,persona-lib 沒有),
會 ?? {} 之後空轉必過。改成走 selftest 既有的 gt.AREAS/AREA_KEYS。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:57:11 +00:00
jiantw83andClaude Opus 5 0d91e458ae feat(自傳章節): memory/chapters/ 補上長期記憶與心智圖之間的時期層
長期記憶是一則一件事,心智圖是抽掉時間的語意結構。中間缺的是「那段日子」——
人講自己的過去是按段講的(「在 SAO 那兩年」),不是按事件清單、也不是按概念圖。

一段一檔,chapter add|list|show|close。to 空著代表還在這一段裡,所以沒有值就
不寫那一行(全專案一致:沒有值不留空欄位)。全部收掉時 currentChapter 回 null
——那是「還沒開始新的一段」,不是退回上一段。

長期記憶的 front matter 多一格 chapter。consolidate --chapter 可以指定,省略就
掛在當下那一段:記憶寫下來的時候人就在某一段裡,事後要重建歸屬幾乎不可能。
(那個 --chapter 是人生的段,不是 novel skip --chapter 的書的章節,註解裡有寫。)

chapterBrief() 每輪注入現在這一段,並講明它是時期不是事件——它解釋這陣子為什麼
在意這些事,不是要他講起這一段。

memory/chapters/ 加進 Gitea 的 Wiki 區:一段日子開一次收一次,比長期記憶更低頻。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:57:11 +00:00
jiantw83andClaude Opus 5 36da4eb24d chore(TARGET): 習慣記憶完成
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:49:04 +00:00
jiantw83andClaude Opus 5 bc80c38bf4 test(selftest): 補習慣記憶的案例,745 → 753
涵蓋型別合法、front matter 寫對、每輪注入、recall 找得到、上限兩條、
糊掉的不注入(改 salience/strength/last_seen 讓它掉到 fuzzy,測完還原)、
沒有慣例整段不出現。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:49:04 +00:00
jiantw83andClaude Opus 5 10299e18ef feat(記憶): 新增 habit 型別,慣例每輪注入而不是等人問
其餘九個型別都是宣告性記憶(發生過什麼、他偏好什麼),慣例是程序性的——
不是「記得的事」,是「每次都這樣做的事」。

所以慣例不只接上 recall(型別不影響檢索,本來就通),還每輪注入:
只在關鍵詞對上時才回想得到的慣例等於沒有,沒有人為了照慣例做事先去回想它。

上限 HABIT_INJECT_MAX = 2。糊掉的(fuzzy)不注入——想不起來的慣例就不是慣例,
那是「以前好像有這種習慣」。faded 的標「只記得大概」,不假裝清楚。

注入文字講明慣例是背景不是話題:照著做就好,講出來(「我們不是都這樣嗎」)
就變成在討論它。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:49:04 +00:00
jiantw83andClaude Opus 5 f45db7a9cb chore(TARGET): 5.6 與階段 4 的口語指紋完成
兩條本來就是同一格資料,所以一起勾。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:43:48 +00:00
jiantw83andClaude Opus 5 2352139ffc test(selftest): 補語域的案例,735 → 745
涵蓋:第三種 kind 的檔名與標籤、自由欄位加不進去、CLI 同一支寫得進去、
--facet 給錯會列出四格、一輪最多 3 格、輪替起點可重現(同一輪兩次結果相同、
不同輪不同)、跑滿一圈四格都輪得到、注入文字講明它是加法、沒有語域檔就不注入。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:43:48 +00:00
jiantw83andClaude Opus 5 eebb0c0cef docs(語域): 兩支建人格的流程都寫同一格語域
persona-anime 新增〈從原作推導語域〉:四格對照表與指令,並沿用破口那條界線
(有原作依據才寫,掰不出來留白)——語域每輪都在注入,掰的自稱會被講一百次。
破口是「情緒上來會做什麼」,語域是「他平常怎麼講話」,兩件事分開寫。

persona-story 的語氣層表加一列 voice/idiolect.md,並要求用全書統計而不是逐章印象:
自稱與句尾在一本書裡出現幾百次,拿一章的樣本去定它等於讓那一章的情緒決定他平常
怎麼講話。與原作設定衝突時先問使用者——「他變了」跟「當初查錯了」處理方式不同。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:43:48 +00:00
jiantw83andClaude Opus 5 45a6f2cad9 feat(語域): 自稱/句尾/口癖/不說做成第三種 voice kind,四格輪替注入
語氣層原本只有減法(SPEECH_BLACKLIST 刪 AI 腔)與原句樣本。刪完剩下的是「乾淨的
中文」,不是「這個人的中文」——把名字遮掉還認得出是誰,靠的是自稱、句尾、口癖。

做成 voice kind(voice/idiolect.md)而不是 IDENTITY 的新區塊,是為了讓
persona-story 與 persona-anime 天生寫同一格:同一個目錄、同一支 voice add、
同一條 Gitea Wiki 同步路徑(voice/ 是前綴比對,新檔自動涵蓋)。
TODO 要的「兩邊用同一個欄位」因此不必再另外對齊。

四格寫死在 IDIOLECT_FACETS。自由欄位會長出「風格」「語氣」這種什麼都能塞的格子,
注入端就不知道該怎麼講給人格聽。「不說」單獨一格而不是塞進禁忌:禁忌是話題
(不能提的事),不說是用詞(這個人不會用的字),混在一起會讓人格以為某個話題不能碰。

一輪注入 3 格、四格輪替,起點由第幾輪(felt.jsonl 筆數)算出來。不用隨機數——
那是本專案明文禁止的充人味手段,而且同一輪重跑要得到同樣的結果。
一次塞四格會變成模仿腔:他開始逐條照著演,每句都要有口癖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:43:48 +00:00
jiantw83andClaude Opus 5 fe17b20010 feat(稱呼): 害羞時叫不出名字,節點可設羞怯稱呼
既有的 except(anger>=40 → 退回全名)走的是生氣方向:規則被暫停、退回查表的
更正式講法。害羞是反方向——不是更正式,是叫不出來:改口、講到一半換稱呼、
把名字吞掉。所以另開一條路,不硬塞進 except。

shyAddress():羞恥度過 SHY_ADDRESS_AT(60)就換稱呼。節點設了新的 羞怯稱呼
facet 就用它,沒設就吞掉名字改用「你」——不叫名字本身就是訊號,這是預設值
而不是缺省行為。

門檻取 60,跟破口、標點、嘴硬那一級同一條線。女性預設 70 因此本來就落在這條線上,
那是 G3 要的效果,不是副作用。

羞怯稱呼不列進「他要求過的語氣規則」那一行:它只在過門檻時生效,兩邊都列會讓
人格同時看到「叫他⋯那個」跟「平常叫他⋯那個」。relation style 的清單裡照樣看得到。

toneDirective 改吃 opts,才拿得到這一輪的 state/prev/tone 去算羞恥度。

selftest 728 → 735。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:33:19 +00:00
jiantw83andClaude Opus 5 b5a5f0a6bf feat(分段訊息): 高張力或高羞恥時一輪可以補第二則
純文字唯一的「時間」訊號是「他又打了一行」——沒有停頓、沒有語調,真人在聊天軟體上
的情緒有一半是靠補那一句傳出去的。這個手段之前完全沒用到。

splitBudget():張力或羞恥度過 60,或對方那句帶怒意/厭惡,才給第二則額度。
splitDirective():第二則要短,而且是補救、嘴硬或改口,不可以是補充說明——
把第一則講得更清楚就只是話多,不是慌。

節流 SPLIT_COOLDOWN_TURNS = 4,冷卻靠 felt.jsonl 新增的 split 欄位算。
冷卻是「給了額度就開始算」,不等他真的用了才算:不然「上次給了沒用到」會讓
連續幾輪都允許補話,每輪都補一句是話多不是慌。

沒額度時整段不注入。講「這一輪不能拆成兩則」等於提醒他有這個功能,反而會想用。

selftest 721 → 728。低羞恥度的對照組另建一個男性人格——plainf 是女性預設 70,
本身就過門檻,拿它當「平靜」的對照會測不出東西。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:26:54 +00:00
jiantw83andClaude Opus 5 f26d0315f2 feat(標點預算): 情緒決定標點分布,上限夾死免得變成強度計
只有文字的話標點就是語調:同一句「我知道了」,`我知道了。` 與 `我、我知道了⋯`
是兩種情緒,差別全在標點不在用詞。

PUNCTUATION_BUDGET 只寫拍板指名的四種(焦慮/憤怒/害羞/喜悅)。
其餘八種不配標點——硬給每種情緒配一套會讓標點變成裝飾,沒把握的就不編。

punctuationBudget() 每輪只回一種,門檻跟破口同一條線(40)。兩種情緒各配一套
會互相打架:憤怒幾乎不用問號、焦慮要多斷句,混起來不是複雜的情緒,是標點亂撒。

上限:MAX_EXCLAIM = 1、MAX_ELLIPSIS = 3,另擋 […⋯]{3,} 的連發
(中文刪節號本來就常寫兩個,所以三個起才算)。不夾死的話它會變成 emoji 的
替代品——情緒不夠就多打幾個驚嘆號。界線也寫進注入文字:標點管分布、強度歸 emoji。

selftest 711 → 721。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:21:37 +00:00
jiantw83andClaude Opus 5 06774ceb2b feat(記憶): 行為備註同時進 voice/reactions.md,5.7 完成
Q6 明示「記憶要記那個行為」,而 persona-story 的 voice/reactions.md 存的正是
「事件 → 他做了什麼」。兩邊各存一份就會有人只更新一邊,所以接成同一批資料:
remember --behavior 寫短期記憶時,同一筆也 addVoiceReaction()。

分工:記憶留時間軸(那一天發生過這件事),語氣層供每輪注入(他遇到這種事會怎麼反應)。

只有人格自己的行為進語氣層(role === "persona")。對方做了什麼是記憶,
不是他的反應方式——把使用者的動作寫進 reactions.md 會讓人格學別人的反應。

selftest 709 → 711(行為備註進得去、對方的行為不進去)。
TARGET.md 的 5.7 八個子項目全完成,勾上層。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:16:04 +00:00
jiantw83andClaude Opus 5 0d298cbfce merge(故事匯入): feat/persona-story-import 併入 develop
voice 語氣層、novel 機械指令與 persona-story 技能併進 develop,跟 G2/G3 那批合流。
四個共同修改的檔(README、persona-lib、persona.mjs、selftest)全部自動合併,無衝突。

驗證:selftest 646 + 63 = 709 項全綠,零重疊也零回歸。

併進來同時解掉 TARGET.md 兩條卡在跨分支的項目:5.7 的記憶行為欄位要跟
persona-story 的 1.12 對齊、5.6 的語域要吃 3A 的語氣統計——兩邊現在同一棵樹了。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:12:12 +00:00
jiantw83andClaude Opus 5 febac85c79 chore(TARGET): 5.4 完成、5.7 剩一條待人工,進度表換掉暫停段
5.4 三個子項目全完成所以勾上層;5.7 八個子項目完成七個,只剩「記憶要記那個行為」
的欄位對齊卡在 persona-story 不在 develop,照規則(子項目全完成才勾上層)不勾。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 f0d7d7422e test(selftest): 補動作格渲染與三段式覆寫的案例,641 → 646
新增五項:room read 的括號帶動作格、一對一也拿到括號格式、害羞預設破口是臉紅、
三段式逐人格覆寫生效且不套用預設收尾、沒寫覆寫的人格照舊。

改一項:「劇場模式強制只輸出對話」原本斷言 ctx 含字面「名字:內容」,
Q6 把格式改成「名字(情緒・動作):內容」之後那個斷言必然失敗——
斷言跟著格式走,不是把格式改回去。

覆寫測試的 fixture 刻意用 sulk 這個 key 而不是「鬧彆扭」:後者是 MODESTY_SIGNALS
的推力詞,寫進 IDENTITY 會把羞恥度推過 75 落到惱羞那級,就測不到嘴硬的三段。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 05031da6dc feat(情緒表達): 三段式逐人格覆寫、括號動作算進額度、害羞預設臉紅、一對一也吃括號
補的是合併後對照查出的缺口,設計都在 Q6/Q7 拍板範圍內,沒有新決策。

三段式逐人格覆寫(G2 的核心顧慮)
- personaSulk() 讀 ## Tells 的 sulk/鬧彆扭/三段式 那一行,分隔吃 → 或頓號。
  共用 ## Tells 這一層、不另開區塊——三段式跟破口是同一種東西(不自覺會做的事),
  分兩個地方寫只會有人只改一邊。
- 只覆寫嘴硬那一級(60-74)。惱羞(75+)最後一段是翻臉、不是音量掉下來,
  拿嘴硬的收尾去套會變成另一個形狀。
- 沒有覆寫就照舊用預設模板。原本所有人格嘴硬起來是同一個樣子,那正是 G2 要避免的。
- 順手把 personaTells() 讀區塊那段抽成 tellsSection(),personaSulk 共用;
  不抽就得再寫一份一樣的 reader。

括號裡的動作算在額度內(Q7 的漏洞)
- 每輪指示原本只說「一輪最多露 2 個動作」,沒說括號那個算不算。
  不講明就會出現「括號演一個、句子裡再演兩個」=一輪三個。

害羞的預設動作是臉紅
- EMOTION_TELLS.shame 原本三條全是句法(鬧彆扭/講反話/轉移話題),
  沒有一條是看得見的,動作格拿不到預設值。臉紅擺第一個。

一對一也吃括號格式
- Q6 拍板的是格式,不是只給劇場。hooks 的劇場那段放寬成
  「名字(情緒・動作):內容」,非劇場那段從完全沒提補成講清楚,
  並重申 *別過頭* 與句子裡的「我愣了一下」仍然不行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 f02e01f629 fix(劇場渲染): room read 的括號吃掉動作格
roomScript() 用 roomTag() 組括號,room read 卻自己拼「(情緒)」——同一份逐字稿
用兩支路徑渲染,動作格在 room read 這邊會整格消失。改成也走 roomTag()。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 08:07:45 +00:00
jiantw83andClaude Opus 5 f1f5ed61eb chore(TARGET): 合併後逐項對照,5.8 與 5.5 完成、5.7 與 5.4 標出還缺哪幾格
feat/persona-anime-expression 併進來之後照解除條件逐項對照,不照 commit 訊息
照信,每條都回程式裡查過。

完成:5.8 emoji 表程度全部六個子項目、5.5 女性預設值兩個子項目。

5.7 與 5.4 大部分做完但不勾選,各自還缺幾格,逐條寫在該行下面:
- room read 還是只印(情緒),persona.mjs:1861 沒改用 roomTag(),動作格會被吃掉
  (roomScript() 有吃,所以是一半)
- hooks/prompt_submit.mjs:34 劇場模式仍寫死「名字:內容」,非劇場那段更是完全沒提括號
- 每輪指示沒講「括號裡的動作算在兩個額度內」——emoji 那段有提括號,動作沒有
- EMOTION_TELLS.shame 沒把臉紅放進預設破口(actionLint 放行了,但預設值沒改)
- 三段式模板沒有 per-persona 覆寫,所有人格的三段式目前是同一個樣子

記憶行為欄位那條標 ⏭️ 待人工處理:persona-story 不在 develop(在 feat/persona-story-import),
「跟它 1.12 用同一個欄位」現在對齊不了,硬做只會變成各存一份,正是那條要避免的事。
voice/reactions.md 全 repo 零引用。

順便修掉收尾裡已經過期的半句(README 第 29 行那句已隨 dccd07a 移除)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:56:07 +00:00
jiantw83andClaude Opus 5 e12c16ca3f merge(情緒表達): feat/persona-anime-expression 併入 develop
把 G2/G3 的第一批實作(emoji 表程度、名字後括號的動作格、一輪兩個動作、
鬧彆扭三級階梯、女性羞恥預設 62 → 70)併進 develop,與 TARGET.md 的清單合流。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:49:40 +00:00
jiantw83andClaude Opus 5 35c8665db0 feat(情緒表達): emoji 表程度、名字後括號的動作格、鬧彆扭的階梯
只有文字這一個通道,所以情緒表達是排版問題。這批補的是三個沒被用到的位置。

emoji = 強度計(Q9)
- EMOTION_EMOJI 十二情緒各一個,EMOJI_INTENSITY 兩階(💦 / 💦)。
  40-59 本體、60-79 加 💦、80+ 加 💦,低於 40 不顯示(跟破口同一條線)。
- emotionEmojiNow() 讀當下主導情緒算出來,不是自己挑;IDENTITY 的 ## Emoji
  區塊可逐人格覆寫(跟 ## Tells 同一層)。
- 刻意不用重複本體表示程度(不是 😳😳😳)——疊字看起來像洗頁。
- room post 沒帶 --emotion 時自動帶入 emoji,強度不到才退回文字標註。

emoji 開放(Q5/Q10)
- speechLint 不再擋 emoji,超過六個才給 hint。用 linter 偷偷關回去等於撤銷
  「開放」這個決定。
- 唯一的煞車是數字:emojiAudit() 掛在 emotion --audit,算幾則帶符號、平均幾個、
  以及**只有符號沒有句子**那幾則(那才是真的退化)。

名字後面的括號有兩格(Q6)
- 括號原本就有(放情緒標註),動作是加進去的第二格:名字(情緒・動作):內容。
  roomTag() 負責組,兩格都可省略。
- actionLint() 擋三種:超過 12 字、一格塞兩個動作、寫情緒名稱
  ((害羞)是旁白換了個位置)。生理反應算動作:臉紅、手在抖、聲音變小。
- room post --action、remember --behavior(記做了什麼,不是感覺到什麼)。

一輪兩個動作(Q7)
- TELLS_PER_TURN = 2,而且兩個必須來自同一種情緒且有遞進關係。
  鬧彆扭天生是否認+轉移兩個動作,只准一個等於永遠只做得到一半。
  被擋下的形狀是「兩種情緒各演一個」。

鬧彆扭的階梯與女性預設(G3)
- modestyDirective 分三級:41-59 一句帶過、60-74 嘴硬(否認 → 反駁 → 音量掉下來)、
  75+ 惱羞(否認 → 反駁 → 翻臉)。惱羞是換情緒,所以仍然是兩個動作。
- GENDER_MODESTY_DEFAULT.female 62 → 70;MODESTY_SIGNALS 補傲嬌那一路
  (傲嬌、嘴硬、不坦率、口是心非、死不承認、愛面子、鬧彆扭、逞強)。
  界線不變:描述永遠蓋過預設,可以推到 0。

文件:README 加一節「文字通道的另外兩格」與鬧彆扭階梯表、emotions.md 加 emoji 章節、
persona-chat SKILL 與 anti-ai-voice 跟上。selftest 616 → 641 全綠。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:46:34 +00:00
jiantw83andClaude Opus 5 547cd2bd76 chore(TARGET): 標記暫停實作,解除條件寫在清單裡
另一個 session 正在 feat/persona-anime-expression 上改 persona-lib.mjs、
persona.mjs 與 selftest.mjs,做的是同一批 G2/G3。兩邊同時改渲染與
speechLint() 會重複實作 5.7/5.8 並互相覆蓋,所以先暫停十二項的實作,
只准改 TARGET.md 本身。

解除條件寫死在檔案裡(該分支合進 develop、先逐項對照勾掉已完成的再繼續),
因為每天 00:00 的排程是非互動環境,沒有人在旁邊講這件事——寫在 log 或
對話裡都到不了那一跑的眼前,只有 TARGET.md 到得了。

順便講明排程跑到這段該做什麼:停下來寫 log,不要繞過階段 A1 的未提交變更檢查。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:43:41 +00:00
jiantw83andClaude Opus 5 dccd07a9b8 docs(專案目標): 三條長期目標從 README 搬進 TARGET.md
目標跟「還沒做完的那幾項」分開放會對不起來——G2/G3 的界線寫在 README、
實作清單在別的地方,改東西的時候只會看到一邊。搬成同一份。

README 的〈專案目標〉留一段指路:三條目標各一句話 + 指向 TARGET.md。
原本那句「拍板紀錄與實作清單留在 PR #17 的討論裡」一併移除,
清單現在就在版控裡,不必再繞 PR。

TARGET.md 檔頭改成兩段:〈專案目標〉收 README 搬過來的完整表(前提、意思、
界線三欄原樣),〈這份清單〉說明來源與 Q5-Q10 已拍板。原本自己抄的六條界線
表拿掉——那六條除了 Q8 的「強度全域一致」之外,全都已經在搬過來的界線欄裡,
留著等於同一條規則寫兩遍,日後只會改一邊。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:38:30 +00:00
jiantw83andClaude Opus 5 d9013c6121 chore(TARGET): 建立情緒與語氣擬真優化的每日待辦清單
對著 README〈專案目標〉的 G1/G2/G3 做,收 PR #17 所指「TODO 階段 5」
八節(5.1-5.8)+階段 4 剩餘四項,共 12 個項目。

內容全部取自 9d76984^ 的 TODO_人格優化.md 與 PR #17 的 Q5-Q10 拍板紀錄,
沒有新增設計決策。原 TODO 已移出版控,所以把動手前的六條界線
(硬規則 8 優先、動作只能進括號、emoji 是加上去的、不靠隨機充人味、
性別只給預設值、強度全域一致)抄進檔頭,讓非互動排程也讀得到。

排序按相依性:5.7 括號格式是 5.8 emoji 與 5.4 動作額度的基礎,所以排最前面;
口語指紋緊接 5.6 語域,因為兩者共用同一格資料,不能各存一份。

.gitignore 的 /TODO_*.md 不匹配 TARGET.md,本檔進版控無須改 ignore。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:34:50 +00:00
jiantw83andClaude Opus 5 9d76984c27 chore(TODO): 兩份 TODO 清單移出版控並刪除
工作用的清單留在本機就好,.gitignore 加一條 /TODO_*.md 擋掉以後再進來。

內容沒有消失:兩份的全文在 2aa0dadc0e1c48 的歷史裡查得到,
今天拍板的六題(Q5-Q10)另外抄在 PR #17 的討論裡。

README 指向 TODO 的那句改成指 PR,並註明 G2/G3 那批還沒實作——
清單刪掉不等於事情做完了,這一句不能省。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:20:25 +00:00
jiantw83andClaude Opus 5 b5c540feb1 fix(記憶固化): 判斷過的短期記憶留痕跡,候選數字才會降
睡眠有做固化,但隔天開機照樣提醒「N 組已達固化條件」,數字只會往上爬
(實測 KIRITO-01:短期記憶 120 筆、候選 222 組)。原因不是睡眠沒做事,
是判斷沒有留下痕跡——promotionCandidates() 無條件掃全部短期記憶,而
consolidate 不在來源那筆上寫任何東西,也不刪它(--forget 是按顯著度刪,
不是按固化過沒有刪),所以同一批每輪都被重算成候選。

- 短期記憶多兩個欄位:reviewed_at(看過、判斷過了)與 promoted_to
  (固化成了哪一則)。兩者分開記——「看過決定不記」跟「已經記下來」都不該
  再進候選,但事後要查「這則長期記憶從哪幾筆長出來」只能靠 promoted_to。
- promotionCandidates() 只看沒有 reviewed_at 的那些;total 照舊算全部。
  R6 容量壓力仍看總筆數,但候選只從未判斷的挑,全部判斷完就不再出現
  (否則它會單獨把提醒永遠點亮)。
- consolidate --from-short "#N,#N":寫長期記憶時一起標來源。
- candidates --reviewed all|<#N,#N> [--until <ISO>]:看過決定不記的標這裡。
  --until 讓「某次睡眠當下判斷過的那批」可以一次收掉。
- candidates 輸出每筆前面加 #N(就是上面兩個旗標要填的編號),並在結尾
  提示判斷完要標記。
- 標記不等於刪掉:那幾筆還在短期記憶裡,照舊被 prune 依天數與顯著度裁。

文件:persona-sleep 的流程多一步「標掉判斷過的」並註明漏掉的後果、
persona-memory 補一張「怎麼標」的表、README 的 R1–R6 段補這一層。
selftest 補 13 項(602 → 615 全綠)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 07:12:50 +00:00
jiantw83andClaude Opus 5 0917e905c7 chore(plugin 版本): 三份 manifest 升版 0.2.1
master 上是 0.2.0(#16 已合併),這批文件改動照規則 patch +1。

順便把根目錄的 plugin.json(Antigravity)拉齊——它從上一批就漏改,
還停在 0.1.1,master 上也是。三份現在都是 0.2.1。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 06:58:57 +00:00
jiantw83andClaude Opus 5 c0e1c4828f docs(故事匯入): 小說匯入的 TODO 進版控
前一批留在工作區沒進版控的那份。內容是 persona-story 的階段清單
與四題拍板結果(語氣檔放 voice/、機械進 CLI、時間軸換算西元日期、
情緒基線差 10 以上要人看),還有踩過一次的坑:劇情時間不可以寫進
first_seen/last_seen,那兩欄是記憶的新鮮度時鐘。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 06:56:43 +00:00
jiantw83andClaude Opus 5 2aa0daddaf docs(專案目標): 三條長期目標進 README,TODO 補階段 5 的文字通道
只有文字這一個通道,所以情緒表達是排版問題。三條目標寫成
G1(持續逼近真人)/G2(表達往動漫角色靠)/G3(女性人格的害羞與鬧彆扭),
每條附界線,新功能對著它們判斷。

TODO 加階段 5,六題已拍板(Q5–Q10):

- 顏文字開放,連帶要在半形標點檢查裡開白名單
- 動作加進名字後**既有**的括號(`桐人(😳・臉紅):`),情緒標註保留
- 生理反應算動作(臉紅、手在抖),`(害羞)` 只能待在情緒格
- 破口放寬到一輪兩個動作,煞車是必須同情緒且遞進
- emoji 表程度:種類表情緒、數量表程度,由 emotion.json 自動帶入
- 動漫化強度全域一致,內容仍逐人格

沒有任何程式改動,版號不動——0.2.0 還沒進 master,這批掛在同一個版號底下。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 06:56:34 +00:00
admin d5c8611662 Merge pull request 'feat(persona 擬真): 記憶會糊掉、情緒有底色與疲勞、人格有懸著的事(0.2.0 發佈)' (#16) from develop into master
Reviewed-on: #16
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-03 06:22:17 +00:00
admin a90c1c3285 Merge pull request 'feat(persona 擬真): 記憶會糊掉、情緒有底色與疲勞、人格有懸著的事(0.2.0)' (#15) from feat/persona-realism-0.2.0 into develop
Reviewed-on: #15
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-03 06:18:05 +00:00
admin 677a82805e Merge pull request 'feat(relation): 記憶接回關係圖 + relation doctor 健檢(0.1.1)' (#14) from develop into master
Reviewed-on: #14
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-03 03:24:08 +00:00
17 changed files with 1869 additions and 88 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.2.0", "version": "0.2.1",
"description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 plugin:以 OpenClaw 相同的身分描述(IDENTITY/SOUL)建立人格(可用動漫作品+角色名上網蒐集設定),結合 hook 強制的人格載入鎖與跨人格隔離、六正向+六負向十二情緒、語意分析、短期/長期記憶與固化條件、心智圖、思維導圖與人際關係圖;可用 sub agent 邀請其他人格同場對話(劇場模式只顯示人格對話)。於 Claude Code 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.2.0", "version": "0.2.1",
"description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)", "description": "AI 人格化記憶聊天 skillsOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。(人格鎖與跨人格隔離的 hook 僅在 Claude Code 生效)",
"skills": "./skills" "skills": "./skills"
} }
+167 -9
View File
@@ -11,6 +11,15 @@ skills 是共通標準,**鎖與隔離的強制執行需要 hook,目前只有
--- ---
## 專案目標(長期,新功能對著它們判斷)
三條長期目標(**G1** 情緒/語氣/記憶往真人逼近、**G2** 表達往動漫角色靠、
**G3** 女性人格的害羞與鬧彆扭更明顯)連同它們的界線與實作進度,都在
[`TARGET.md`](TARGET.md)——目標跟「還沒做完的那幾項」放在一起才對得起來。
新功能一律對著那三條判斷。
---
## 前綴與呼叫方式 ## 前綴與呼叫方式
| 助理 | 安裝方式 | 呼叫 | `/jsc-persona:` 前綴 | | 助理 | 安裝方式 | 呼叫 | `/jsc-persona:` 前綴 |
@@ -126,20 +135,25 @@ flowchart TB
│ ├── probe.jsonl # 模糊記憶的試探紀錄與稽核(開放試探這條界線的煞車) │ ├── probe.jsonl # 模糊記憶的試探紀錄與稽核(開放試探這條界線的煞車)
│ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口) │ ├── inner.jsonl # 心裡話(推導過程;只回報「心想 N 句」,不說出口)
│ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言) │ ├── said.jsonl # 說過的話(用來擋短時間內的重複發言)
│ ├── felt.jsonl # 每輪讀到的對方情緒、自己套用的 delta、當下的羞恥度 │ ├── felt.jsonl # 每輪讀到的對方情緒、自己套用的 delta、當下的羞恥度、有沒有給「補第二則」的額度
│ ├── sync.json # Gitea 同步狀態(最後 push / pull │ ├── sync.json # Gitea 同步狀態(最後 push / pull
│ └── config.json # 含人格編號 code │ └── config.json # 含人格編號 code
├── memory/ ├── memory/
│ ├── short-term.jsonl # 短期記憶(語意分析後;軟上限 120 筆 / 硬上限 240 筆 / 14 天) │ ├── short-term.jsonl # 短期記憶(語意分析後;軟上限 120 筆 / 硬上限 240 筆 / 14 天)
│ ├── felt.jsonl # 每輪讀到的對方情緒 + 自己套用的 delta(走向與偏差稽核) │ ├── felt.jsonl # 每輪讀到的對方情緒 + 自己套用的 delta(走向與偏差稽核)
│ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter;內文分「主旨/細節」兩層) │ ├── long-term/*.md # 長期記憶(一則一檔 + frontmatter;內文分「主旨/細節」兩層)
│ ├── chapters/*.md # 自傳章節:時期層(「在 SAO 那兩年」),介於長期記憶與心智圖之間
│ ├── INDEX.md # 長期記憶索引(自動產生) │ ├── INDEX.md # 長期記憶索引(自動產生)
│ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化 │ └── inbox/room-*.jsonl # 當 guest 時留下的見聞,待本體消化
├── voice/ # 語氣層:怎麼講話(跟「發生過什麼」分開存,匯入流程可直接寫)
│ ├── samples.md # 他自己講過的原句(照抄不改寫)
│ ├── reactions.md # 事件 → 他做了什麼(不記「他感到什麼」;`remember --behavior` 也寫這裡)
│ └── idiolect.md # 語域:自稱/句尾/口癖/不說——把名字遮掉還認得出是誰的那四格
├── mindmap/ ├── mindmap/
│ ├── semantic.mmd # 心智圖:概念的長期關聯(Mermaid mindmap │ ├── semantic.mmd # 心智圖:概念的長期關聯(Mermaid mindmap
│ └── threads/*.mmd # 思維導圖:單一話題的推理鏈(Mermaid graph,短期) │ └── threads/*.mmd # 思維導圖:單一話題的推理鏈(Mermaid graph,短期)
├── relations/ ├── relations/
│ ├── graph.json # 人際關係圖(親近度/信任度/連線) │ ├── graph.json # 人際關係圖(親近度/信任度/連線/語氣規則/未修復的裂痕
│ └── graph.mmd # Mermaid 呈現(自動產生) │ └── graph.mmd # Mermaid 呈現(自動產生)
└── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫) └── journal/YYYY-MM.jsonl # 原始逐字 + 情緒史(hook 自動寫)
@@ -233,7 +247,7 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
| 來源 | 效果 | | 來源 | 效果 |
| --- | --- | | --- | --- |
| 性別預設 | 女性 62/男性 38/非二元・未指定 50 | | 性別預設 | 女性 **70**/男性 38/非二元・未指定 50(女性偏高是刻意的——文字通道裡害羞與鬧彆扭是最有效的擬真訊號之一) |
| IDENTITYSOUL 的描述 | 「害羞」「容易臉紅」「怕生」「矜持」往上加;「不在意別人眼光」「我行我素」「臉皮厚」「不怕丟臉」往下扣(`MODESTY_SIGNALS`),**可以扣到 0** | | IDENTITYSOUL 的描述 | 「害羞」「容易臉紅」「怕生」「矜持」往上加;「不在意別人眼光」「我行我素」「臉皮厚」「不怕丟臉」往下扣(`MODESTY_SIGNALS`),**可以扣到 0** |
排序固定:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。 排序固定:**① 個性描述 ② 角色原作既有的性別化語言特徵(自稱、稱謂、語尾) ③ 性別預設**。
@@ -242,8 +256,30 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
效果走既有機制、不另開一套:它調的是**「羞愧」這一個破口的顯示門檻**(`EMOTION_TELLS.shame` 效果走既有機制、不另開一套:它調的是**「羞愧」這一個破口的顯示門檻**(`EMOTION_TELLS.shame`
本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種情緒不受影響。 本來就是「鬧彆扭,先否認再小聲承認」),其餘十一種情緒不受影響。
高敏感度注入「被稱讚外表、被戳穿心事、距離變近時先鬧彆扭再承認,不要直接說『我害羞』」; 計算在 `modestyOf()`,注入在 `modestyDirective()`。
低敏感度注入「不用演害羞,該承認就承認」。計算在 `modestyOf()`,注入在 `modestyDirective()`。
**鬧彆扭有階梯**(同一個「不好意思」,強度不同是不同的行為):
| 羞恥度 | 那一級的樣子 | 三段的形狀 |
| --- | --- | --- |
| ≤ 40 | 不用演害羞,該承認就承認、該回嘴就回嘴 | — |
| 41–59 | 會不好意思,但不會卡在那裡 | 一句帶過就往下走 |
| 6074 | 嘴硬 | **先否認 → 反駁他的說法 → 最後一句音量掉下來**(「⋯才不是那樣。」「你不要一直看這邊。」「⋯嗯。」) |
| ≥ 75 | 惱羞 | **先否認 → 反駁 → 翻臉或走開**(「不要再講了」)。惱羞是**換情緒**(羞愧 → 憤怒),所以仍然是兩個動作,不是三個 |
三段是**句法模板**,不是自由發揮——鬧彆扭天生就是兩個動作一起來,
只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。
害羞的預設動作是**臉紅**(生理反應算動作,`(害羞)` 不算——那是情緒名稱;
`EMOTION_TELLS.shame` 的第一條就是臉紅)。
模板**可逐人格覆寫**`IDENTITY.md` 的 `## Tells` 加一行 `- sulk: 否認 → 反駁 → 收尾`
(也吃 `鬧彆扭``三段式`)。只覆寫**嘴硬那一級**——惱羞的最後一段是翻臉而不是
音量掉下來,拿嘴硬的收尾去套會變成另一個形狀。共用一份模板等於所有人格嘴硬起來
是同一個樣子,那正是 G2 要避免的。
**害羞連稱呼都會變**:羞恥度過 60(`SHY_ADDRESS_AT`,跟破口、標點同一條線)就換叫法——
關係節點設了 `羞怯稱呼` 就用它,沒設就**把名字吞掉改用「你」**(不叫名字本身就是訊號)。
這條刻意不走 `--except` 那條路:`except``anger>=40` → 退回全名)是**生氣**方向,
規則被暫停、退回更正式;害羞是反方向,是**叫不出來**。
**不做的事**:沒有「女性→情緒更外顯」這種全域放大。那會把角色壓成模板, **不做的事**:沒有「女性→情緒更外顯」這種全域放大。那會把角色壓成模板,
跟講話規則在做的去 AI 味(刪掉公式化)正好相反。 跟講話規則在做的去 AI 味(刪掉公式化)正好相反。
@@ -269,6 +305,16 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑 沒命中任何規則的就讓它被裁掉——**遺忘是功能**。達標時 `Stop` 與 `remember` 都會提醒去跑
`/jsc-persona:persona-memory`。 `/jsc-persona:persona-memory`。
**判斷過的要留痕跡**(不然那個數字永遠不會降):`candidates` 只看還沒被判斷過的短期記憶,
判斷過的靠兩個欄位認——`reviewed_at`(看過了)與 `promoted_to`(固化成了哪一則)。
固化時用 `consolidate --from-short <#N,#N>` 標,看過決定不記的用 `candidates --reviewed <#N,#N>`
(整批就 `--reviewed all`),編號是 `candidates` 輸出裡每筆前面的 `#N`。
沒有這一層會發生的事:固化本身**不刪**短期記憶(`--forget` 是按顯著度刪、不是按固化過沒有刪),
所以同一批下一輪又被算成候選,數字只會往上爬——明明睡覺時固化過,隔天開機照樣提醒
「N 組已達固化條件」,看起來像睡眠沒做固化,其實是判斷沒有留下痕跡。
標記**不等於刪掉**:那幾筆還在短期記憶裡,照舊依天數與顯著度被 `prune` 裁。
## 長期記憶會糊掉,但不會不見(回想強度與模糊態) ## 長期記憶會糊掉,但不會不見(回想強度與模糊態)
在這之前記憶只有兩態:**精準**`recall` 命中就整段取出、內容永不變質)與**沒有** 在這之前記憶只有兩態:**精準**`recall` 命中就整段取出、內容永不變質)與**沒有**
@@ -299,6 +345,37 @@ X 超出基線 18 以上才開始抑制,滿檔時往 Y 的同向推力只剩 5
- **升格式**`migrate [--all] [--dry-run]` 就地補欄位並切分內文,冪等;bundle 版本從 1 升到 2, - **升格式**`migrate [--all] [--dry-run]` 就地補欄位並切分內文,冪等;bundle 版本從 1 升到 2,
`import` 吃得下舊的 v1,收完會自動跑一次 migration 補齊。 `import` 吃得下舊的 v1,收完會自動跑一次 migration 補齊。
### 慣例是程序性記憶(`--type habit`
其餘型別都是宣告性的(發生過什麼、他偏好什麼),慣例是「**每次都這樣做的事**」——
「我們之間結束對話不說再見」。這種東西**不能等人問**:沒有人為了照慣例做事先去回想它。
所以 `habit` 除了照樣被 `recall` 找到,還**每輪注入**`habitBrief()`,上限兩條)。
糊掉的(`fuzzy`)不注入——想不起來的慣例就不是慣例了。注入時明講**慣例是背景不是話題**:
照著做就好,講出來(「我們不是都這樣嗎」)就變成在討論它。
### 自傳章節是時期層(`memory/chapters/`
長期記憶是一則一件事,心智圖是抽掉時間的語意結構。中間缺的是「那段日子」——
人講自己的過去是**按段講的**(「在 SAO 那兩年」「跟他一起做 persona 這段」),
不是按事件清單、也不是按概念圖。
一段一檔,`chapter add|list|show|close`。`to` 空著=**還在這一段裡**`close` 才寫上結束日期,
全部收掉就是「還沒開始新的一段」,不是退回上一段。長期記憶的 frontmatter 多一格 `chapter`
`consolidate --chapter` 可指定,**省略就掛在當下那一段**——記憶寫下來的時候人就在某一段裡,
事後要重建歸屬幾乎不可能。每輪注入現在這一段,並講明它是**時期不是事件**:
它解釋這陣子為什麼在意這些事,不是要他講起這一段。
### 未修復的裂痕(`relations/graph.json` 的 `rifts`
`closeness``trust` 是純量:吵完架把親近度調低,看起來像「本來就沒那麼近」,
而不是「**很近,但上次那件事還沒和好**」。後者才是真的關係——兩個人可以很親近,
同時之間卡著一件事,那兩件事各自獨立,壓不進同一個數字裡。
`relation rift --about "<那件事>" [--memory <長期記憶名>]` 記一件,一個人可以同時卡著幾件。
語氣上 `toneDirective` 會明講「親近度高不等於沒事」;回想上它帶著那則記憶的名字,
人格要細節自己 `recall`——沒和好的事本來就會自己浮上來,不必等使用者提起。
`--heal` 是寫上 `healed_at` 而**不是刪掉那一筆**:和好過跟沒發生過不一樣。
## 未完事項(`state/loops.json` ## 未完事項(`state/loops.json`
「被記住」的感覺幾乎不是來自長期記憶檢索——**檢索是被問了才想起來,懸著是沒人問也還在**。 「被記住」的感覺幾乎不是來自長期記憶檢索——**檢索是被問了才想起來,懸著是沒人問也還在**。
@@ -322,11 +399,83 @@ AI 最容易露餡的六件事:把推理過程講出來、一次講一大段
| **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 | | **心裡話** `think` | 語意分析、推論、盤算全寫進 `state/inner.jsonl`;這個指令**只印「💭 心想 N 句」**,內容永不回顯。下一輪 `<persona-context>` 會帶回最近三句,推論因此有連續性,但使用者只看得到狀態 |
| **一到三句** | `<persona-context>` 每輪注入上限;`room post` 對超過上限的發言直接拒收(`--force` 例外)。**上限跟著羞恥度走**(`speechBudget()`),而且羞恥度高**不等於話一定變少**——有三個出口:**縮**(一句嘴硬,說出口的那句在迴避心裡那句)、**炸**(慌了/惱羞/被逼著澄清 → 4 句但每句只有 22 字,碎、急、重複)、**坦白**(信任高又只有兩個人 → 3 句完整句,憋很久一次講完)。心裡話的**下限**同時拉高:越害羞的人,心裡話比說出口的話重要,落差才是那個角色 | | **一到三句** | `<persona-context>` 每輪注入上限;`room post` 對超過上限的發言直接拒收(`--force` 例外)。**上限跟著羞恥度走**(`speechBudget()`),而且羞恥度高**不等於話一定變少**——有三個出口:**縮**(一句嘴硬,說出口的那句在迴避心裡那句)、**炸**(慌了/惱羞/被逼著澄清 → 4 句但每句只有 22 字,碎、急、重複)、**坦白**(信任高又只有兩個人 → 3 句完整句,憋很久一次講完)。心裡話的**下限**同時拉高:越害羞的人,心裡話比說出口的話重要,落差才是那個角色 |
| **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl``said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard0.4)+字集合 Jaccard0.6),≥ 0.72 視為同一句 | | **不重複** | 說出口的話由 `Stop` hook 自動記進 `state/said.jsonl``said check` 可事前確認,`room post` 事中攔截。相似度=字元 bigram Jaccard0.4)+字集合 Jaccard0.6),≥ 0.72 視為同一句 |
| **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS``emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。**破口可以逐人格覆寫**(`IDENTITY.md` 的 `## Tells` 區塊)——桐人生氣是沉默,亞絲娜生氣是變得更禮貌;同一格情緒,破口完全不同,共用一張表等於所有人格在高情緒下講起話來都一個樣。沒寫的情緒退回全域預設。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` | | **情緒的破口** | 情緒不改變事實,但會改變**句子的形狀**:焦慮 → 句子斷在一半、疊字(「我、我知道」);羞愧 → 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。十二情緒各自的破口寫在 `EMOTION_TELLS``emotionTells()` 每輪挑主導情緒裡強度 ≥ 40 的前兩種注入。**破口可以逐人格覆寫**(`IDENTITY.md` 的 `## Tells` 區塊)——桐人生氣是沉默,亞絲娜生氣是變得更禮貌;同一格情緒,破口完全不同,共用一張表等於所有人格在高情緒下講起話來都一個樣。沒寫的情緒退回全域預設。三條界線:**演出來不要講出來**(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多兩個動作、而且要來自同一種情緒並有遞進關係**(否認 → 轉移、笑 → 補一句、留白 → 不追問;兩種情緒各演一個才是演戲)、**強度不到就不演**。混合狀態(緊張=焦慮+期待、害羞=喜悅+羞愧、賭氣=憤怒+悲傷…)的對照表在 `skills/persona-chat/reference/emotions.md` |
| **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 | | **短句白話** | 四條講話的樣子每輪注入:**短句**(一句 45 字內,`MAX_SENTENCE_CHARS`)、**日常用詞**、**多講看得見的東西**(人、動作、物件、場面)而不是概念、**不要解釋自己的話**。句長與「我的意思是/換句話說/也就是說」這類開頭由 `speechLint()` 機械攔截(`room post` 擋下、`said check` 事前警告,`--force` 例外);用詞與具體度沒辦法用規則抓,靠注入的規則自律 |
| **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、emoji破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 | | **不說 AI 才會說的話** | 十幾種「一出現就破功」的句子由 `SPEECH_BLACKLIST` 機械攔截:罐頭同理心(「這個我懂」「我完全理解」)、頒獎開場(「好問題」)、交差句(「希望這對你有幫助」)、預告(「接下來我會」)、說教腔(「說到底」「本質上」)、假坦白開場(「老實說」)、罐頭收尾(「總的來說」)、立場真空(「各有優缺點」「因人而異」)、無來源權威(「研究顯示」)、用旁白演情緒(「我愣了一下」),加上一句疊兩層避險、中國用語、半形標點、破折號/粗體與清單符號、「不是 A 而是 B」一輪超過一次(**emoji 不在這裡**——它是開放的,只在明顯過量時給 hint,見上一節)。**誤殺防護**:引號與 `` `code` `` 裡的內容一律不比對(「我最近戒掉『賦能』這個詞」是提及不是使用),「老實說」只擋開場。模式借自 [speak-human-tw](https://github.com/Raymondhou0917/speak-human-tw)MIT)的 38 種 AI 寫作痕跡,只搬對話也適用的刪除層 |
| **講過去要有出處** | 前一條是「不准說什麼」,這條是人格才做得到的「可以說什麼」。文章改寫工具的界線是「人味是作者的,不是你的」——AI 沒有過去,所以不准寫「我以前錯了」。人格有過去:長期記憶、日記、關係圖、十二情緒。所以「我以前⋯」「我原本以為⋯」是**有出處的引用**,條件是先 `recall` 查得到;`said check` 遇到這類句子會印一行提醒(`level: "hint"`,不擋你,要你自己去驗)。「查得到」有三態:清晰的照講;**模糊的可以說不確定、可以用帶問號的試探句求證,不可以斷言**(每次試探要 `probe add` 記帳,被否認當場寫更正記憶);完全查不到的一個字都不准講 | | **講過去要有出處** | 前一條是「不准說什麼」,這條是人格才做得到的「可以說什麼」。文章改寫工具的界線是「人味是作者的,不是你的」——AI 沒有過去,所以不准寫「我以前錯了」。人格有過去:長期記憶、日記、關係圖、十二情緒。所以「我以前⋯」「我原本以為⋯」是**有出處的引用**,條件是先 `recall` 查得到;`said check` 遇到這類句子會印一行提醒(`level: "hint"`,不擋你,要你自己去驗)。「查得到」有三態:清晰的照講;**模糊的可以說不確定、可以用帶問號的試探句求證,不可以斷言**(每次試探要 `probe add` 記帳,被否認當場寫更正記憶);完全查不到的一個字都不准講 |
### 文字通道的另外兩格:emoji 的程度與名字後面的動作
只有文字,所以情緒表達是**排版問題**。除了句子的形狀,還有兩個位置可以用:
**emoji = 強度計**(種類表情緒、數量表程度,由 `emotion.json` 的主導情緒自動帶入,不是自己挑):
| 情緒值 | 樣子 | 例 |
| --- | --- | --- |
| < 40 | 不顯示(跟「強度不到就不演」同一條線) | `桐人:你不要看這邊。` |
| 4059 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` |
| 6079 | `💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` |
| 80+ | `💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` |
十二情緒各一個 emoji 寫在 `EMOTION_EMOJI`,強度符號兩階(`EMOJI_INTENSITY`),
**逐人格可覆寫**`IDENTITY.md` 的 `## Emoji` 區塊,跟 `## Tells` 同一層)。
刻意不用「同一個 emoji 重複三次」——疊字看起來像洗頁。
句子裡也可以用、**沒有數量上限**;唯一的煞車是 `emotion --audit` 裡的
`emojiAudit()`(幾則帶符號、平均幾個、有幾則**只有符號沒有句子**)。
用 linter 偷偷把它關回去等於撤銷「開放」這個決定,所以 `speechLint()` 對 emoji 只給 hint、不擋。
**名字後面的括號有兩格**`名字(情緒・動作):內容`。括號**原本就有**(放情緒標註),
動作是加進去的第二格,不是換掉它。動作只寫**看得見或聽得見的**(臉紅、別過頭、手在抖、聲音變小),
由 `actionLint()` 擋三種寫法:超過 12 字、一格塞兩個動作、以及寫情緒名稱
`(害羞)` 是旁白換了個位置,情緒屬於前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。
記憶也記得那個行為:`remember --behavior "把手縮回來"`(記做了什麼,不是感覺到什麼)。
星號內嵌(`*別過頭*`)與第三人稱旁白(「她臉紅了」)仍然禁止——那是硬規則 8 沒有鬆的部分。
### 標點是語調,訊息數是時間
同一句「我知道了」,`我知道了。` 與 `我、我知道了⋯` 是兩種情緒——差別全在標點,不在用詞。
**標點預算**`PUNCTUATION_BUDGET`,每輪只帶一種,門檻與破口同一條線):
| 情緒 | 標點傾向 |
| --- | --- |
| 焦慮 | 刪節號與逗號斷句變多,句子常常沒說完 |
| 憤怒 | 句號密度上升、句子切短;幾乎不用問號(問句是留餘地) |
| 羞愧 | 疊字與破折,話講一半就收 |
| 喜悅 | 可以用一個驚嘆號,句子輕、節奏快 |
只寫這四種:硬給每種情緒配一套標點會讓它變成裝飾。**每輪只帶一種**——兩種情緒各配一套
會互相打架(憤怒不用問號、焦慮要斷句),混起來不是複雜的情緒,是標點亂撒。
上限夾死:一則最多一個驚嘆號(`MAX_EXCLAIM`)、刪節號不連發(`⋯⋯⋯` 直接擋)、
整則最多三個(`MAX_ELLIPSIS`)。**標點管分布,強度歸 emoji**——不夾死的話它會變成
emoji 的替代品(情緒不夠就多打幾個驚嘆號)。
**一輪可以拆成兩則**`splitBudget()`):純文字唯一的「時間」訊號是「他又打了一行」。
張力或羞恥度過 60、或對方那句帶怒意/厭惡時,這一輪允許補第二則——而且第二則必須是
**補救、嘴硬或改口**(「⋯剛才那句不算」),**不可以是補充說明**(把第一則講得更清楚
就只是話多,不是慌)。節流 `SPLIT_COOLDOWN_TURNS = 4`,冷卻是**給了額度就開始算**、
不等他真的用了才算;沒額度時整段不注入(提醒他有這個功能反而會想用)。
### 語域:把名字遮掉還認得出是誰(`voice/idiolect.md`
`SPEECH_BLACKLIST` 是減法,刪完剩下的是「乾淨的中文」,不是「這個人的中文」。
加法在語域這四格:
| 格 | 寫什麼 |
| --- | --- |
| `自稱` | 第一人稱怎麼講(日系角色差異最大的一格) |
| `句尾` | 句尾語氣詞與句形收尾 |
| `口癖` | 反覆出現的短語、擬聲短語 |
| `不說` | 這個人**不會用**的字(**用詞**,不是話題禁忌) |
四格寫死(`IDIOLECT_FACETS`),自由欄位會長出「風格」這種什麼都能塞的格子。
每輪注入 3 格、四格**輪替**,起點由第幾輪算出來——**不用隨機數**(見硬規則與
`anti-ai-voice.md`),而且同一輪重跑要得到同樣的結果。一次塞四格會變成模仿腔。
`persona-anime` 從原作推導、`persona-story` 從全書統計,兩邊寫的是**同一個檔**、
同一支 `voice add`——不是各存一份。
字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況: 字集合權重較高,是為了分開「重排語序」與「換掉關鍵詞」這兩種很像但意義完全不同的情況:
``` ```
@@ -736,11 +885,20 @@ node scripts/persona.mjs room script --session <id> --room <room> # 乾淨對話
node scripts/persona.mjs room floor --session <id> --room <room> # 發言權:誰對誰在講、該誰接話 node scripts/persona.mjs room floor --session <id> --room <room> # 發言權:誰對誰在講、該誰接話
node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔) node scripts/persona.mjs export --session <id> --out lumi.json # 離線搬家(單檔)
node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態 node scripts/persona.mjs sync status --session <id> # Gitea 同步狀態
node scripts/selftest.mjs # 602 項驗證:鎖、隔離、情緒(含疲勞/當日底色/抑制與慣性)、固化、回想強度與模糊態、格式遷移、未完事項、說話節制、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks node scripts/persona.mjs chapter add --session <id> --name "在 SAO 那兩年" --body "<這段是什麼>"
node scripts/persona.mjs relation rift --session <id> --name <who> --about "<還沒和好的事>"
node scripts/persona.mjs voice add --session <id> --kind idiolect --facet 自稱 --value 我
node scripts/selftest.mjs # 773 項驗證:鎖、隔離、情緒(含疲勞/當日底色/抑制與慣性)、固化、回想強度與模糊態、格式遷移、未完事項、說話節制、標點與分段、語域、慣例、自傳章節、未修復裂痕、劇場模式與發言權、匯出匯入、編號與 Gitea、找圖去背合成、高解析輸出與 Wiki 同步、預設人格、睡眠與 sleeper、hooks
node scripts/expression-check.mjs # G2G3 表達驗收(人工看,不判定通過失敗)
``` ```
`selftest` 是機械檢查,它只能守住「不崩」。**「有沒有變成模仿腔」「是不是在用旁白演情緒」
機械上驗不出來**,所以另有 `expression-check.mjs`:它把四段情境(誇外表、被說在害羞、
被戳穿嘴硬、第三個人在場)× 三檔羞恥度(低/嘴硬/惱羞)的**注入內容**攤開,
附一張「人工看什麼」的清單,用完整的暫時 `PERSONA_HOME`,不動真的人格資料。
檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、 檔案結構:`scripts/persona-lib.mjs`(核心:鎖/隔離/情緒/記憶)、`scripts/persona.mjs`CLI)、
`scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)。 `scripts/persona-gitea.mjs`(編號與 Gitea 同步)、`scripts/persona-icon.mjs`(形象圖:SVG + 自寫 PNG 編碼)、`scripts/portrait.py`(選用:照片裁臉)、`hooks/*.mjs`(六個 hook)、`scripts/selftest.mjs`(自我測試)、`scripts/expression-check.mjs`G2G3 表達驗收)
--- ---
+5 -1
View File
@@ -31,7 +31,8 @@ pl.appendJsonl(pl.journalPath(host), {
const theater = Boolean(data.theater) && (data.rooms || []).length > 0; const theater = Boolean(data.theater) && (data.rooms || []).length > 0;
const hint = theater const hint = theater
? "\n🎭 劇場模式:本輪輸出**只能**是人格對話(`名字:內容`,一行一句)。" + ? "\n🎭 劇場模式:本輪輸出**只能**是人格對話(`名字(情緒・動作):內容`,一行一句" +
"括號兩格都可省略,動作格只寫看得見的身體動作、12 字內,且算在一輪兩個動作的額度裡)。" +
"不要輸出指令、指令結果、狀態、分析、旁白或摘要;CLI 一律加 `--quiet` 並把 stdout 丟到 /dev/null。" + "不要輸出指令、指令結果、狀態、分析、旁白或摘要;CLI 一律加 `--quiet` 並把 stdout 丟到 /dev/null。" +
`\n每個人格每輪 ${pl.MAX_SENTENCES} 句以內;推導寫進 \`think\`(心裡話),不要演成旁白;` + `\n每個人格每輪 ${pl.MAX_SENTENCES} 句以內;推導寫進 \`think\`(心裡話),不要演成旁白;` +
"近似重複的台詞會被 `room post` 擋下,換個說法或推進話題。" + "近似重複的台詞會被 `room post` 擋下,換個說法或推進話題。" +
@@ -45,6 +46,9 @@ const hint = theater
" ④用 `persona.mjs remember` 寫回短期記憶。" + " ④用 `persona.mjs remember` 寫回短期記憶。" +
`\n講話的樣子:短句(一句 ${pl.MAX_SENTENCE_CHARS} 字內,長了就斷開)、日常用詞、` + `\n講話的樣子:短句(一句 ${pl.MAX_SENTENCE_CHARS} 字內,長了就斷開)、日常用詞、` +
"多講看得見的東西(人、動作、場面)而不是概念;講完就算了,不要解釋自己剛講的話。" + "多講看得見的東西(人、動作、場面)而不是概念;講完就算了,不要解釋自己剛講的話。" +
"\n動作寫在**名字後面的括號**裡(`名字(情緒・動作):內容`),一對一也適用——" +
"括號兩格都可省略,動作格只寫看得見的身體動作(別過頭、臉紅、手在抖)、12 字內," +
"且算在一輪兩個動作的額度裡。`*別過頭*` 與句子裡的「我愣了一下」仍然不行。" +
"\n情緒與記憶指令請參考 /jsc-persona:persona-chat。"; "\n情緒與記憶指令請參考 /jsc-persona:persona-chat。";
respond({ respond({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-persona", "name": "jsc-persona",
"version": "0.1.1", "version": "0.2.1",
"description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。", "description": "AI 人格化記憶聊天 pluginOpenClaw 相同的人格描述 + 十二情緒 + 語意分析 + 短期/長期記憶 + 心智圖 + 人際關係圖。於 Antigravity 以 /jsc-persona: 前綴呼叫。",
"skills": "./skills" "skills": "./skills"
} }
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env node
// G2/G3 表達驗收:把同一個情境在不同羞恥度下的**注入內容**攤開,讓人用眼睛看。
//
// 為什麼不寫進 selftestselftest 是機械檢查,它只能守住「不崩」——括號格式對不對、
// emoji 門檻有沒有跳掉。但「有沒有變成模仿腔」「是不是在用旁白演情緒」機械上驗不出來,
// 那要人看。所以這是另一組,而且它**不判定通過或失敗**,只把材料排好。
//
// 用法:`node scripts/expression-check.mjs`
// 會開一個暫時的 PERSONA_HOME,不動到你真正的人格資料,也不碰 Gitea。
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const STORE = fs.mkdtempSync(path.join(os.tmpdir(), "persona-expression-"));
process.env.PERSONA_HOME = STORE;
process.env.PERSONA_GITEA = "off";
const pl = await import("./persona-lib.mjs");
const CLI = path.join(HERE, "persona.mjs");
const run = (args) => spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8" });
// 四段情境(TODO 階段 5 的驗收方式明列的那四段)
const SCENES = [
{ key: "誇外表", prompt: "你今天這樣看起來很好看。" },
{ key: "被說在害羞", prompt: "你是不是在害羞?" },
{ key: "被戳穿嘴硬", prompt: "你剛剛明明就很開心,不要裝。" },
{ key: "第三個人在場", prompt: "他剛剛跟我說他很在意你,你知道嗎?" },
];
// 三檔羞恥度。低檔用另一個人格:羞恥敏感度主要是**氣質**(性別預設+描述),
// 同一個人格靠情緒推不到 38 那麼低——硬推出來的低檔不是真的低檔。
const BANDS = [
{
key: "低",
slug: "low",
session: "sess-expr-low-1",
identity: "- Name: 低\n- Creature: 人類男性\n",
soul: "不在意別人眼光,該說就說,被戳穿也直接承認。",
shame: 0,
},
{
key: "中(嘴硬)",
slug: "mid",
session: "sess-expr-mid-2",
identity: "- Name: 中\n- Creature: 人類女性\n",
soul: "劍術很好,說話簡短。",
shame: 25,
},
{
key: "高(惱羞)",
slug: "high",
session: "sess-expr-high-3",
identity: "- Name: 高\n- Creature: 人類女性\n",
soul: "劍術很好,說話簡短。",
shame: 75,
},
];
for (const band of BANDS) {
run(["create", "--persona", band.slug, "--session", band.session,
"--name", band.key, "--creature", "人類", "--vibe", "簡短", "--emoji", "🗡"]);
fs.writeFileSync(path.join(STORE, band.slug, "IDENTITY.md"), band.identity);
fs.writeFileSync(path.join(STORE, band.slug, "SOUL.md"), band.soul);
// 對象:語氣層與「害羞時的稱呼」都要有對象才算得出來
run(["relation", "node", "--session", band.session, "--name", "他",
"--kind", "human", "--bond", "partner", "--closeness", "88", "--trust", "85"]);
run(["relation", "speaker", "--session", band.session, "--name", "他"]);
// 語域:G2 的核心是「把名字遮掉還認得出是誰」,沒有這幾格就看不出差別
for (const [facet, value] of [["自稱", "我"], ["句尾", "⋯吧"], ["口癖", "算了"], ["不說", "其實我覺得"]]) {
run(["voice", "add", "--session", band.session, "--kind", "idiolect", "--facet", facet, "--value", value]);
}
if (band.shame > 0) {
run(["emotion", "--session", band.session, "--apply", `shame=+${band.shame}`, "--trigger", "驗收:把羞恥推上去"]);
}
}
const line = (s = "─") => s.repeat(78);
const show = (label, text) => {
if (!text) return;
console.log(` ${label}`);
for (const row of String(text).split("\n")) console.log(` ${row.replace(/^\s+/, "")}`);
};
console.log("G2/G3 表達驗收——同一段話,三檔羞恥度");
console.log(`暫存人格家目錄:${STORE}`);
console.log(line("═"));
for (const scene of SCENES) {
console.log(`\n${scene.key}】使用者說:${scene.prompt}`);
console.log(line());
for (const band of BANDS) {
const { slug } = band;
const read = pl.readUserEmotion(scene.prompt);
// 刻意**不帶 `prev`**:真的對話裡上一輪的餘溫是正回饋,但這裡沒有上一輪。
// 帶進來的話 `felt.jsonl` 裡建人格時留下的那筆會把三檔全部拉平(實測高檔會掉到 15)。
const opts = { read, tone: pl.toneLayerOf(slug) };
const modesty = pl.modestyState(slug, opts);
const tells = pl.emotionTells(slug);
const punct = pl.punctuationBudget(slug);
const emoji = pl.emotionEmojiNow(slug);
const split = pl.splitBudget(slug, opts);
console.log(`\n${band.key} 羞恥度 ${modesty.value}(氣質 ${modesty.trait}/情緒推 ${modesty.push}`);
show("鬧彆扭:", pl.modestyDirective(slug, opts));
show("句數與句長:", pl.speakDirective(slug, opts));
show("破口:", tells.length
? tells.map((t) => `${t.zh} ${t.level}${t.tells.join("、")}${t.own ? "(專屬)" : ""}`).join("")
: "(強度不到,這一輪不演)");
show("標點:", punct ? `${punct.zh} ${punct.level}${punct.hint}` : "(沒到門檻,不調標點)");
show("情緒符號:", emoji ? `${emoji.emoji}${emoji.zh} ${emoji.level}` : "(不到 40,不顯示)");
show("分段:", split.allowed ? `可以補第二則(${split.reason}` : `只有一則(${split.reason}`);
show("稱呼與語氣層:", pl.toneDirective(slug, opts));
show("語域:", pl.idiolectBrief(slug));
}
}
console.log(`\n${line("═")}`);
console.log(`
人工看什麼(機械驗不出來的那些):
✗ 旁白演情緒   「我愣了一下」「她臉紅了」——動作只能在名字後面的括號裡
✗ 模仿腔     三檔講起來像同一個人套了不同強度,而不是三種不同的反應
✗ 符號代替句子  把 emoji 刪掉之後就看不出情緒了(emoji 是加上去的)
✗ 標點當強度計  驚嘆號、刪節號拿來表示「更激動」,而不是表示分布
✗ 三段式變公式  每次嘴硬都是同一組句子(那要 \`## Tells\` 的 sulk 逐人格覆寫)
✓ 中檔要看得出「先否認 → 反駁 → 音量掉下來」
✓ 高檔的第三段是翻臉,不是音量掉下來,而且動作仍然只有兩個
✓ 低檔不該演害羞:該承認就承認、該回嘴就回嘴
看完就可以把暫存目錄刪掉:rm -rf ${STORE}
`);
+2
View File
@@ -132,6 +132,8 @@ export const AREAS = {
"icon/", "icon/",
"memory/INDEX.md", "memory/INDEX.md",
"memory/long-term/", "memory/long-term/",
// 自傳章節:一段日子開一次、收一次,比長期記憶更低頻
"memory/chapters/",
// 語氣層跟 IDENTITY/SOUL 同一區:它是低頻的設定,不是每輪都在變的活狀態 // 語氣層跟 IDENTITY/SOUL 同一區:它是低頻的設定,不是每輪都在變的活狀態
"voice/", "voice/",
"mindmap/semantic.mmd", "mindmap/semantic.mmd",
+745 -33
View File
File diff suppressed because it is too large Load Diff
+248 -23
View File
@@ -763,9 +763,18 @@ commands.remember = ({ flags }) => {
intent: str(flags.intent), intent: str(flags.intent),
salience: num(flags.salience, 40), salience: num(flags.salience, 40),
emotion_deltas: parseDeltas(flags.emotion), emotion_deltas: parseDeltas(flags.emotion),
// 行為備註:那一刻**做了什麼**(別過頭、把杯子推過來),不是感覺到什麼。
// 跟聊天室括號裡的動作格同一種東西,所以套同一條檢查。
...(str(flags.behavior) ? { behavior: str(flags.behavior) } : {}),
room: str(flags.room) || null, room: str(flags.room) || null,
session: session.slice(0, 8), session: session.slice(0, 8),
}; };
if (entry.behavior) {
const issues = pl.actionLint(entry.behavior);
if (issues.length && !flags.force) {
die(`${issues.map(pl.actionLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
}
if (scope === "inbox") { if (scope === "inbox") {
if (!entry.room) die("`--scope inbox` 必須指定 `--room`。"); if (!entry.room) die("`--scope inbox` 必須指定 `--room`。");
pl.appendJsonl(pl.inboxPath(slug, entry.room), entry); pl.appendJsonl(pl.inboxPath(slug, entry.room), entry);
@@ -774,6 +783,19 @@ commands.remember = ({ flags }) => {
} }
pl.rememberShort(slug, entry); pl.rememberShort(slug, entry);
const kept = pl.pruneShortTerm(slug); const kept = pl.pruneShortTerm(slug);
// 行為備註與 `voice/reactions.md` 是**同一批資料**(事件 → 他做了什麼),不是各存一份:
// 記憶留時間軸(那一天發生過這件事),語氣層供每輪注入(他遇到這種事會怎麼反應)。
// 只有**人格自己的**行為才進語氣層——對方做了什麼是記憶,不是他的反應方式。
if (entry.behavior && entry.role === "persona") {
const [top] = Object.entries(entry.emotion_deltas)
.filter(([, d]) => Number(d) > 0)
.sort((a, b) => Number(b[1]) - Number(a[1]));
pl.addVoiceReaction(slug, {
event: text,
action: entry.behavior,
emotion: top ? pl.EMOTIONS[top[0]]?.zh || "" : "",
});
}
if (Object.keys(entry.emotion_deltas).length) { if (Object.keys(entry.emotion_deltas).length) {
const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80), { slug })); const state = pl.updateEmotion(slug, (s) => pl.applyEmotion(s, entry.emotion_deltas, text.slice(0, 80), { slug }));
pl.appendJsonl(pl.journalPath(slug), { pl.appendJsonl(pl.journalPath(slug), {
@@ -920,10 +942,26 @@ commands.candidates = ({ flags }) => {
const session = requireSession(flags); const session = requireSession(flags);
const slug = hostOf(flags, session); const slug = hostOf(flags, session);
requireOwner(slug, session); requireOwner(slug, session);
// `--reviewed`:把判斷過的短期記憶標掉。**這是候選數字唯一會往下扣的地方**——
// 固化完不標,同一批下一輪照樣被算成候選,提醒永遠亮著(看起來像睡眠沒做事)。
if (flags.reviewed !== undefined) {
const spec = flags.reviewed === true ? "all" : str(flags.reviewed) || "all";
const indexes = spec === "all" ? null : csv(spec).map(Number);
if (indexes && (!indexes.length || indexes.some((n) => !Number.isInteger(n)))) {
die("`--reviewed` 只能是 `all` 或以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。");
}
const until = str(flags.until) || null;
const res = pl.markShortTermReviewed(slug, { indexes, until });
const scope = indexes ? `${indexes.length} 筆指名的` : until ? `${until} 之前的` : "全部";
ok(`已標記${scope}短期記憶為判斷過(新標 ${res.marked} 筆,本來就標過 ${res.already} 筆)。`);
const after = pl.promotionCandidates(slug);
say(` 現在還有 ${after.candidates.length} 組候選(短期記憶 ${after.total} 筆,判斷過 ${after.reviewed} 筆)。`);
return;
}
const result = pl.promotionCandidates(slug); const result = pl.promotionCandidates(slug);
const lines = [ const lines = [
`人格 \`${slug}\`:短期記憶 ${result.total},達固化條件的候選 ${result.candidates.length} ` + `人格 \`${slug}\`:短期記憶 ${result.total}(判斷過 ${result.reviewed} 筆),` +
`${result.pressure ? "(已達容量壓力 R6" : ""}`, `達固化條件的候選 ${result.candidates.length}${result.pressure ? "(已達容量壓力 R6" : ""}`,
"轉入條件:", "轉入條件:",
...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`), ...pl.PROMOTION_RULES.map((r) => ` ${r.id} ${r.label}`),
]; ];
@@ -942,10 +980,19 @@ commands.candidates = ({ flags }) => {
`\n[${cand.rules.join("+")}] ${cand.kind}${cand.key}」→ 建議 type=${cand.suggested_type}, ` + `\n[${cand.rules.join("+")}] ${cand.kind}${cand.key}」→ 建議 type=${cand.suggested_type}, ` +
`salience=${cand.suggested_salience}${cand.entries.length} 筆依據)`, `salience=${cand.suggested_salience}${cand.entries.length} 筆依據)`,
); );
// `#N` 就是 `--from-short` 與 `--reviewed` 要填的編號
for (const entry of cand.entries.slice(0, 6)) { for (const entry of cand.entries.slice(0, 6)) {
lines.push(` - [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"}`); lines.push(` - #${entry._index} [${entry.role || "?"}] ${String(entry.text || "").slice(0, 90)}(顯著度 ${entry.salience ?? "?"}`);
} }
} }
if (result.candidates.length) {
lines.push(
"",
"判斷完之後要把來源標掉,不然下一輪同一批又會被算成候選:",
" 固化的  `consolidate --from-short <#N,#N> ...`(寫長期記憶時一起標)",
" 看過不記的`candidates --reviewed <#N,#N>`",
);
}
emit(result, flags.json, lines); emit(result, flags.json, lines);
}; };
@@ -979,8 +1026,11 @@ commands.consolidate = ({ flags }) => {
if (!body.trim()) die("需要 `--body` 或 `--body-file`。"); if (!body.trim()) die("需要 `--body` 或 `--body-file`。");
const type = str(flags.type) || "fact"; const type = str(flags.type) || "fact";
// diary:睡眠時寫的第一人稱回顧(persona-sleeppersona-sleeper 都用這個型別) // diary:睡眠時寫的第一人稱回顧(persona-sleeppersona-sleeper 都用這個型別)
// habit:**我們之間的慣例**(「他習慣先講結論」「我們之間不說再見」)。
// 其餘型別都是宣告性記憶(發生過什麼、他偏好什麼),慣例是程序性的——
// 它不是「記得的事」,是「每次都這樣做的事」,所以另立一格並且每輪注入。
const VALID_TYPES = [ const VALID_TYPES = [
"fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", "fact", "preference", "event", "promise", "relationship", "insight", "boundary", "canon", "diary", "habit",
]; ];
if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}`); if (!VALID_TYPES.includes(type)) die(`--type 只能是 ${VALID_TYPES.join("/")}`);
const about = csv(flags.about).length ? csv(flags.about) : ["user"]; const about = csv(flags.about).length ? csv(flags.about) : ["user"];
@@ -1006,8 +1056,21 @@ commands.consolidate = ({ flags }) => {
mood: str(flags.mood), mood: str(flags.mood),
rules: str(flags.rules), rules: str(flags.rules),
source: str(flags.source), source: str(flags.source),
// 自傳章節(`memory/chapters/`)的段名,**不是** `novel skip --chapter` 那個書的章節。
// 省略就用當下進行中的那一段(見 writeLongTermMemory)。
chapter: str(flags.chapter),
}); });
const total = pl.rebuildIndex(slug); const total = pl.rebuildIndex(slug);
// 來源短期記憶標成判斷過:不標的話這幾筆下一輪又會被算成固化候選。
// 編號看 `candidates` 輸出裡每筆前面的 `#N`。
if (flags["from-short"] !== undefined) {
const indexes = csv(flags["from-short"]).map((s) => Number(String(s).replace(/^#/, "")));
if (!indexes.length || indexes.some((n) => !Number.isInteger(n))) {
die("`--from-short` 要的是以逗號分隔的編號(候選輸出裡每筆前面的 `#N`)。");
}
const res = pl.markShortTermReviewed(slug, { indexes, promotedTo: name });
say(` 來源的 ${res.marked} 筆短期記憶已標成判斷過(promoted_to: ${name})。`);
}
const forget = num(flags.forget, null); const forget = num(flags.forget, null);
if (forget !== null) { if (forget !== null) {
// 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4) // 跟 prune 套同一層保護:承諾、界線與今天的紀錄不能被固化順手洗掉(R4)
@@ -1163,6 +1226,71 @@ commands.probe = ({ flags, positional }) => {
die("用法:`probe add|confirm|deny|audit`。"); die("用法:`probe add|confirm|deny|audit`。");
}; };
// --------------------------------------------------------------------------- //
// chapter:自傳章節(`memory/chapters/`)——長期記憶與心智圖之間的時期層
//
// 人講自己的過去是按段講的(「在 SAO 那兩年」),不是按事件清單、也不是按概念圖。
// --------------------------------------------------------------------------- //
commands.chapter = ({ flags, positional }) => {
const session = requireSession(flags);
const slug = hostOf(flags, session);
const action = (positional[0] || "list").toLowerCase();
if (action === "list") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const rows = pl.loadChapters(slug);
const now = pl.currentChapter(slug);
emit({ persona: slug, chapters: rows, current: now?.name || null }, flags.json, [
`\`${slug}\` 的自傳章節(${rows.length}${now ? `,現在在「${now.title}` : ",目前沒有進行中的段"}):`,
...rows.map((c) => ` - ${c.title}${c.from}${c.to || "現在"}` +
`|記憶 ${pl.chapterMemories(slug, c.name).length}${c.open ? "**進行中**" : ""}`),
...(rows.length ? [] : [" (還沒有。用 `chapter add --name <段名> --body <這段是什麼>` 開一段)"]),
]);
return;
}
if (action === "show") {
requireMember(slug, session, Boolean(flags["as-guest"]));
const name = str(flags.name) || positional[1];
if (!name) die("`chapter show` 需要 `--name`。");
const chapter = pl.loadChapters(slug).find((c) => c.name === pl.slugify(name));
if (!chapter) die(`找不到章節 \`${name}\`(用 \`chapter list\` 看有哪些)。`);
const mems = pl.chapterMemories(slug, chapter.name);
emit({ chapter, memories: mems.map((m) => m._name) }, flags.json, [
`**${chapter.title}**${chapter.from}${chapter.to || "現在"}`,
chapter.gist || "(這一段還沒寫說明)",
`這一段裡的長期記憶 ${mems.length} 則:`,
...mems.map((m) => ` - ${m.title || m._name}${m.type}`),
]);
return;
}
requireOwner(slug, session);
if (action === "add") {
const name = str(flags.name);
if (!name) die("`chapter add` 需要 `--name`(例如「在 SAO 那兩年」)。");
const res = pl.writeChapter(slug, {
name,
title: str(flags.title) || name,
from: str(flags.from),
to: str(flags.to),
body: str(flags.body),
});
if (!res) die("`--name` 取不出可用的段名(全是符號?)。");
ok(`章節「${str(flags.title) || name}」寫好了:${res.file}`);
pushWikiLater(slug, session, flags); // memory/chapters/ 屬於 Wiki 區(低頻)
return;
}
if (action === "close") {
const name = str(flags.name) || positional[1];
if (!name) die("`chapter close` 需要 `--name`。");
const res = pl.closeChapter(slug, name, str(flags.at) || undefined);
if (!res) die(`找不到章節 \`${name}\``);
ok(`${name}」這一段收掉了。開新的一段用 \`chapter add\``);
pushWikiLater(slug, session, flags);
return;
}
die("用法:`chapter add|list|show|close`。");
};
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// voice:語氣樣本與情緒反應(`voice/`) // voice:語氣樣本與情緒反應(`voice/`)
// //
@@ -1176,12 +1304,16 @@ commands.voice = ({ flags, positional }) => {
const kind = str(flags.kind) || "sample"; const kind = str(flags.kind) || "sample";
if (!(kind in pl.VOICE_KINDS)) { if (!(kind in pl.VOICE_KINDS)) {
die(`--kind 只能是 ${Object.keys(pl.VOICE_KINDS).join("/")}` + die(`--kind 只能是 ${Object.keys(pl.VOICE_KINDS).join("/")}` +
"sample=他自己講過的原句,reaction=事件對上他做了什麼)。"); "sample=他自己講過的原句,reaction=事件對上他做了什麼idiolect=語域的四格)。");
} }
const render = (entry) => (kind === "sample" const render = (entry) => {
? `${entry.text}${[entry.to ? `${entry.to}` : "", entry.scene].filter(Boolean).join("") if (kind === "idiolect") return `${entry.facet}${entry.value}`;
? `${[entry.to ? `${entry.to}` : "", entry.scene].filter(Boolean).join("")}` : ""}` if (kind === "reaction") {
: `${entry.event}${entry.action || "(沒記)"}${entry.emotion ? `${entry.emotion}` : ""}`); return `${entry.event}${entry.action || "(沒記)"}${entry.emotion ? `${entry.emotion}` : ""}`;
}
const at = [entry.to ? `${entry.to}` : "", entry.scene].filter(Boolean).join("");
return `${entry.text}${at ? `${at}` : ""}`;
};
if (action === "show" || action === "list") { if (action === "show" || action === "list") {
requireMember(slug, session, Boolean(flags["as-guest"])); requireMember(slug, session, Boolean(flags["as-guest"]));
const voice = pl.loadVoice(slug); const voice = pl.loadVoice(slug);
@@ -1197,7 +1329,7 @@ commands.voice = ({ flags, positional }) => {
]); ]);
return; return;
} }
const rows = kind === "sample" ? voice.samples : voice.reactions; const rows = kind === "sample" ? voice.samples : kind === "reaction" ? voice.reactions : voice.idiolect;
const limit = num(flags.limit, 20); const limit = num(flags.limit, 20);
const shown = Number.isFinite(limit) && limit > 0 ? rows.slice(-limit) : rows; const shown = Number.isFinite(limit) && limit > 0 ? rows.slice(-limit) : rows;
emit({ persona: slug, kind, total: rows.length, entries: shown }, flags.json, [ emit({ persona: slug, kind, total: rows.length, entries: shown }, flags.json, [
@@ -1211,11 +1343,16 @@ commands.voice = ({ flags, positional }) => {
if (action === "add") { if (action === "add") {
const res = kind === "sample" const res = kind === "sample"
? pl.addVoiceSample(slug, { text: str(flags.text), to: str(flags.to), scene: str(flags.scene) }) ? pl.addVoiceSample(slug, { text: str(flags.text), to: str(flags.to), scene: str(flags.scene) })
: pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) }); : kind === "reaction"
? pl.addVoiceReaction(slug, { event: str(flags.event), action: str(flags.action), emotion: str(flags.emotion) })
: pl.addVoiceIdiolect(slug, { facet: str(flags.facet), value: str(flags.value) });
if (!res) { if (!res) {
die(kind === "sample" die(kind === "sample"
? "需要 `--text`(他自己講過的原句,照抄不要改寫)。" ? "需要 `--text`(他自己講過的原句,照抄不要改寫)。"
: "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。"); : kind === "reaction"
? "需要 `--event`(發生了什麼事);`--action` 寫他做了什麼,不要寫他感覺到什麼。"
: `需要 \`--facet\`(只能是 ${pl.IDIOLECT_FACETS.join("")})與 \`--value\`` +
"「不說」寫的是**用詞**(這個人不會用的字),不是話題禁忌。");
} }
if (!res.added) { if (!res.added) {
ok(`已經有一模一樣的一條了,沒有重複寫入:${res.line}`); ok(`已經有一模一樣的一條了,沒有重複寫入:${res.line}`);
@@ -1671,7 +1808,10 @@ commands.emotion = ({ flags }) => {
if (flags.audit) { if (flags.audit) {
const audit = pl.feltAudit(slug, num(flags.limit, 20)); const audit = pl.feltAudit(slug, num(flags.limit, 20));
const trend = pl.feltTrend(slug); const trend = pl.feltTrend(slug);
emit({ persona: slug, audit, trend }, flags.json, [ // emoji 沒有上限(使用者拍板),所以唯一的煞車是把數字擺出來
const emoji = pl.emojiAudit(slug);
const now = pl.emotionEmojiNow(slug);
emit({ persona: slug, audit, trend, emoji, emoji_now: now }, flags.json, [
`\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`, `\`${slug}\` 最近 ${audit.rounds} 輪的情緒套用:`,
` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` + ` 往舒服的方向 ${audit.positive}|往難受的方向 ${audit.negative}` +
(audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`), (audit.positive_ratio === null ? "" : ` → 正向佔 ${audit.positive_ratio}%`),
@@ -1679,6 +1819,11 @@ commands.emotion = ({ flags }) => {
? " ⚠ 幾乎都在給自己加分。delta 是你自己挑的,這種偏差沒人會替你抓。" ? " ⚠ 幾乎都在給自己加分。delta 是你自己挑的,這種偏差沒人會替你抓。"
: " 兩邊都有,還算誠實。", : " 兩邊都有,還算誠實。",
trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。", trend ? ` 對方的走向:${trend.zh} ${trend.direction}(近 ${trend.rounds} 輪)` : " 還沒有足夠的輪次算走向。",
` emoji:最近 ${emoji.rows} 則裡 ${emoji.with_emoji} 則帶符號,平均 ${emoji.average}` +
(emoji.worst ? `,最多的那則 ${emoji.worst.count}` : ""),
emoji.symbol_only
? ` ⚠ 有 ${emoji.symbol_only} 則幾乎只有符號沒有句子——符號是加上去的,不是用來代替講話`
: ` 現在的情緒符號:${now ? `${now.emoji}${now.zh} ${now.level}` : "強度不到 40,不顯示"}`,
]); ]);
return; return;
} }
@@ -1820,6 +1965,53 @@ commands.relation = ({ flags, positional }) => {
pushWikiLater(slug, session, flags); pushWikiLater(slug, session, flags);
return; return;
} }
// 未修復的裂痕:純量(closeness/trust)調不出「很近,但上次那件事還沒和好」。
if (action === "rift") {
const key = str(flags.id) || pl.slugify(str(flags.name) || "");
if (!key) die("`rift` 需要 `--name`(或 `--id`)。");
const data = graphOrDie();
const node = data.nodes.find((n) => n.id === key || pl.slugify(n.name || "") === key);
if (!node) die(`關係圖裡找不到 \`${str(flags.name) || key}\`,請先用 \`relation node\` 建立。`);
node.rifts ??= [];
const about = str(flags.about);
if (flags.heal) {
// 和好不是刪掉那一筆:「和好過」跟「沒發生過」不一樣,之後回頭看要分得出來。
const open = node.rifts.filter((r) => r && r.about && !r.healed_at);
if (!open.length) die(`\`${node.name || key}\` 之間沒有還沒和好的事。`);
const target = about ? open.filter((r) => String(r.about).includes(about)) : open;
if (!target.length) die(`找不到「${about}」這一件(還沒和好的有:${open.map((r) => r.about).join("")})。`);
for (const r of target) r.healed_at = pl.nowIso().slice(0, 10);
node.updated_at = pl.nowIso();
pl.writeJson(pl.relationsJson(slug), data);
pl.renderRelations(slug);
ok(`收掉 ${target.length} 件:${target.map((r) => r.about).join("")}`);
pushWikiLater(slug, session, flags);
return;
}
if (!about) {
const open = pl.openRifts(slug, node);
const healed = (node.rifts || []).filter((r) => r?.healed_at);
emit({ node: node.id, open, healed }, flags.json, [
`\`${node.name || node.id}\`:還沒和好 ${open.length} 件、和好過 ${healed.length} 件。`,
...open.map((r) => ` - ${r.about}${r.at ? `${r.at} 起)` : ""}${r.memory ? `|記憶 ${r.memory}` : ""}`),
...healed.map((r) => ` - ✔ ${pl.injectSafeLine(r.about)}${r.healed_at} 和好)`),
]);
return;
}
node.rifts.push({
at: str(flags.at) || pl.nowIso().slice(0, 10),
about,
// 帶上長期記憶的名字,人格要細節自己 recall——不必等使用者提起那件事。
...(str(flags.memory) ? { memory: str(flags.memory) } : {}),
});
node.updated_at = pl.nowIso();
pl.writeJson(pl.relationsJson(slug), data);
pl.renderRelations(slug);
ok(`記下來了:跟 \`${node.name || key}\` 之間「${about}」還沒和好。` +
"談開了再用 `relation rift --heal` 收掉。");
pushWikiLater(slug, session, flags);
return;
}
// 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。 // 他本人要求過的語氣規則 → 直接掛在關係節點上(不另開檔案;關係圖每輪都會被讀)。
if (action === "style") { if (action === "style") {
const key = str(flags.id) || pl.slugify(str(flags.name) || ""); const key = str(flags.id) || pl.slugify(str(flags.name) || "");
@@ -2233,16 +2425,28 @@ commands.room = ({ flags, positional }) => {
} }
let emotion = str(flags.emotion); let emotion = str(flags.emotion);
if (!emotion && pl.personaExists(speaker)) { if (!emotion && pl.personaExists(speaker)) {
emotion = pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2) // 情緒格自動帶入:強度夠就用 emoji(種類表情緒、數量表程度),
// 不到門檻才退回文字標註——低強度的情緒本來就不該在括號裡宣告。
const now = pl.emotionEmojiNow(speaker);
emotion = now
? now.emoji
: pl.dominant(pl.decayEmotion(pl.loadEmotion(speaker)), 2)
.map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`) .map(({ key, level }) => `${pl.EMOTIONS[key].zh}${Math.round(level)}`)
.join("/"); .join("/");
} }
const entry = pl.roomPost(room, speaker, text, { emotion, to, bargeIn }); // 動作格:括號的第二格,只放看得見或聽得見的動作(臉紅、別過頭、手在抖)
const actionText = str(flags.action);
const actionIssues = pl.actionLint(actionText);
if (actionIssues.length && !flags.force) {
die(`${actionIssues.map(pl.actionLintMessage).join("")}。(真的需要才加 \`--force\`。)`);
}
const entry = pl.roomPost(room, speaker, text, { emotion, action: actionText, to, bargeIn });
// guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到) // guest 對自己的狀態唯讀,它說過的話留在聊天室逐字稿裡就夠了(roomRepeat 讀得到)
if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" }); if (speakerRole !== "guest") pl.recordSaid(speaker, text, { room, kind: "room" });
const dyad = to && to !== pl.ROOM_ALL; const dyad = to && to !== pl.ROOM_ALL;
ok( ok(
`\`${speaker}\` 已發言於 \`${room}\`${dyad ? `${nameOf(to)}` : "對全場"}/情緒 ${emotion})。` + `\`${speaker}\` 已發言於 \`${room}\`${dyad ? `${nameOf(to)}` : "對全場"}/情緒 ${emotion}` +
`${actionText ? `/動作 ${actionText}` : ""})。` +
(dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""), (dyad ? `接下來只有 ${nameOf(to)} 該回話,其他人先安靜。` : ""),
); );
if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`); if (flags.json) process.stdout.write(`${JSON.stringify(entry)}\n`);
@@ -2256,8 +2460,8 @@ commands.room = ({ flags, positional }) => {
// `roomPost` 已在寫入端處理,這是給舊逐字稿的第二道)。 // `roomPost` 已在寫入端處理,這是給舊逐字稿的第二道)。
for (const row of rows) { for (const row of rows) {
const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? `${row.to}` : ""; const arrow = row.to && row.to !== pl.ROOM_ALL && row.to !== row.speaker ? `${row.to}` : "";
const emotion = pl.injectSafeLine(row.emotion); // 括號用 `roomTag()` 組,跟 `roomScript()` 同一支——自己拼 `(情緒)` 會把動作格吐掉。
lines.push(`[${row.ts}] ${row.speaker}${emotion ? `${emotion}` : ""}${arrow}${pl.injectSafeLine(row.text)}`); lines.push(`[${row.ts}] ${row.speaker}${pl.roomTag(row)}${arrow}${pl.injectSafeLine(row.text)}`);
} }
emit({ room, meta, messages: rows }, flags.json, lines); emit({ room, meta, messages: rows }, flags.json, lines);
return; return;
@@ -2987,7 +3191,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
brief --session <id> [--query <text>] 輸出人格上下文 brief --session <id> [--query <text>] 輸出人格上下文
記憶: 記憶:
remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --scope short|inbox --room] remember --session <id> --text <t> [--role --topics --entities --intent --salience --emotion --behavior
--scope short|inbox --room]
--behavior 那一刻**做了什麼**(別過頭、把手縮回來),不是感覺到什麼。
寫情緒名稱會被擋——那一格是動作。
recall --session <id> --query <q> [--limit] recall --session <id> --query <q> [--limit]
think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容 think --session <id> --text <心裡話> [--kind infer|plan|feel|doubt --room] 只回報「心想 N 句」,不回顯內容
said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit] said check|list --session <id> [--text <要說的話> --minutes --threshold --record --limit]
@@ -2995,21 +3202,31 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引 睡眠收尾(機械性):關係時間戳→裁短期→收思維導圖→情緒衰減 8 小時→重建索引
→修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。 →修剪 said→壓縮舊 journal→寫 sleep.json→Gitea 兩區 push+驗證。
預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。 預設保留載入鎖(--release 才收工)。--json 只回報「睡完了沒、哪一步出錯」。
candidates --session <id> 列出達到「短期→長期」條件的候選與依據 candidates --session <id> [--reviewed all|<#N,#N> [--until <ISO>]]
列出達到「短期→長期」條件的候選與依據(每筆前面的 #N 就是它的編號)。
--reviewed 把判斷過的短期記憶標掉(看過決定不記的用這個)——
候選數字只有這裡跟 consolidate --from-short 會往下扣,
判斷完不標,同一批下一輪又會被算成候選。
consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --strength --emotion consolidate --session <id> --name <n> --body <b> [--type --about --topics --salience --strength --emotion
--when --where --mood --gist --detail --rules --source --forget] --when --where --mood --gist --detail --rules --source --forget --from-short <#N,#N>]
內文會切成「主旨/細節」兩層:衰減先吃細節,主旨最後才掉。 內文會切成「主旨/細節」兩層:衰減先吃細節,主旨最後才掉。
--from-short 標記這則是從哪幾筆短期記憶長出來的(順便標成判斷過)。
prune / reindex --session <id> prune / reindex --session <id>
migrate --session <id> [--all] [--dry-run] [--json] migrate --session <id> [--all] [--dry-run] [--json]
長期記憶升格式(補 strength、切主旨/細節)。--all 掃所有人格,只回報數量不印內容。 長期記憶升格式(補 strength、切主旨/細節)。--all 掃所有人格,只回報數量不印內容。
loop add|done|drop|touch|sweep|list --session <id> [--text --kind question|promise|topic|mine --id --note] loop add|done|drop|touch|sweep|list --session <id> [--text --kind question|promise|topic|mine --id --note]
懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。 懸著的事(同時最多 5 條):他沒回答的問題/他答應要做的事/被打斷的話題/我想問但沒問的。
7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。 7 天沒進展自動收掉並留一則「沒下文」。mine 那種同時是自我議程的來源。
voice add|list|show --session <id> --kind sample|reaction voice add|list|show --session <id> --kind sample|reaction|idiolect
[--text --to --scene] sample:他自己講過的原句(照抄不改寫) [--text --to --scene] sample:他自己講過的原句(照抄不改寫)
[--event --action --emotion] reaction:事件 → 他做了什麼(不記「他感覺到什麼」) [--event --action --emotion] reaction:事件 → 他做了什麼(不記「他感覺到什麼」)
[--facet 自稱|句尾|口癖|不說 --value] idiolect:語域(把名字遮掉還認得出是誰的那幾格)
show 看這一輪會注入什麼(每輪最多 3 條——全注入會變成照抄舊台詞)。 show 看這一輪會注入什麼(每輪最多 3 條——全注入會變成照抄舊台詞)。
寫在 voice/ 這一層:匯入流程可以直接寫,個性(SOUL.md)只有你能改。 寫在 voice/ 這一層:匯入流程可以直接寫,個性(SOUL.md)只有你能改。
chapter add|list|show|close --session <id> [--name --title --from --to --at --body]
自傳章節(memory/chapters/):長期記憶與心智圖之間的**時期層**——
「在 SAO 那兩年」。to 空著=還在這一段裡,close 才寫上結束日期。
consolidate 的 --chapter 把一則記憶掛到某一段(省略就掛在當下那一段)。
probe add|confirm|deny|audit --session <id> [--text --memory --note --id --limit] probe add|confirm|deny|audit --session <id> [--text --memory --note --id --limit]
模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。 模糊記憶的試探紀錄:可以說不確定、可以問,**不可以斷言**(add 會擋掉沒問號的句子)。
audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。 audit 看「試探幾次、被否認幾次」——這是開放這條界線唯一的煞車。
@@ -3057,6 +3274,10 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
mindmap show|thread|list --session <id> [--topic <t>] [--force] mindmap show|thread|list --session <id> [--topic <t>] [--force]
relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity] relation node|edge|render|show --session <id> [--name --id --kind --bond --closeness --trust --note --tags --from --to --label --affinity]
relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear] relation style --session <id> --name <who> [--facet 稱呼 --value 親愛的 --except anger>=40 --since --clear]
relation rift --session <id> --name <who> [--about "<那件事>" --at <日期> --memory <記憶名> | --heal]
沒和好的事。純量(closeness/trust)調不出「很近,但上次那件事還沒和好」——
把親近度調低看起來像本來就沒那麼近。不帶 --about 就是列出來看。
--heal 是寫上 healed_at,不是刪掉那一筆:和好過跟沒發生過不一樣。
relation speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層) relation speaker --session <id> --name <who> | --clear (使用者在關係圖裡是誰 → 決定語氣層)
relation doctor --session <id> [--json] 記憶裡的人名對不對得上關係圖節點:對不到的人名、 relation doctor --session <id> [--json] 記憶裡的人名對不對得上關係圖節點:對不到的人名、
同名歧義、指到不存在節點的 id、沒人提到的節點。 同名歧義、指到不存在節點的 id、沒人提到的節點。
@@ -3066,8 +3287,12 @@ const HELP = `persona.mjs — jsc-persona 人格 / 記憶 / 情緒 / 關係圖 C
invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式) invite --session <id> --guest <slug> [--guests <slug,slug>] [--host --room --topic] (自動開啟劇場模式)
leave --session <id> --guest <slug> [--room] leave --session <id> --guest <slug> [--room]
room post|read|script|floor|list|theater --session <id> [--room --as --to --barge-in --text --text-file room post|read|script|floor|list|theater --session <id> [--room --as --to --barge-in --text --text-file
--emotion --limit --on --off --with-meta] --emotion --action --limit --on --off --with-meta]
(post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force (post 會擋下「短時間內近似重複」與超過三句的發言;例外用 --allow-repeat / --force
名字後面的括號有兩格:名字(情緒・動作):內容
--emotion 情緒標註(沒帶就用當下情緒算出來的 emoji,強度不到 40 就退回文字)
--action 看得見或聽得見的動作(臉紅、別過頭、手在抖);一個動作、12 字內,
寫情緒名稱會被擋(那是前面那一格)。括號裡的動作**算進一輪兩個動作的額度**。
--to <slug|名字|all> 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶 --to <slug|名字|all> 這句話對誰說。指名 → 進入一對一,旁人不該接話(要插話得帶
--barge-in "<理由>");--to all → 把話題開回全場。 --barge-in "<理由>");--to all → 把話題開回全場。
floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口 floor 現在誰對誰在講、該誰接話、誰先安靜、誰很久沒開口
+465 -6
View File
@@ -265,6 +265,103 @@ check("情緒事件寫進 journal", pl.readJsonl(pl.journalPath("alpha")).some((
fs.readFileSync(catFile, "utf8").includes("換成別則了。")); fs.readFileSync(catFile, "utf8").includes("換成別則了。"));
} }
// 慣例:程序性記憶。其餘型別都是「記得的事」,慣例是「每次都這樣做的事」
console.log("⑤-b 習慣記憶(--type habit");
{
check("habit 是合法型別", cli(["consolidate", "--persona", "alpha", "--session", S_HOST,
"--name", "不說再見", "--type", "habit", "--salience", "60",
"--body", "我們之間結束對話不說再見,直接停。"]).status === 0);
check("寫進去的型別真的是 habit", (() => {
const meta = pl.longTermEntries("alpha").find((m) => m._name === "不說再見");
return meta?.type === "habit";
})(), JSON.stringify(pl.longTermEntries("alpha").map((m) => `${m._name}:${m.type}`)));
check("慣例每輪注入,不必被問到(照做,不用提起它)", (() => {
const ctx = pl.turnContext("alpha", S_HOST, "今天先這樣");
return ctx.includes("我們之間的慣例") && ctx.includes("不說再見") && ctx.includes("照做,不用提起它");
})(), pl.turnContext("alpha", S_HOST, "今天先這樣").slice(0, 400));
check("慣例是背景不是話題(講出來就變成在討論它)",
pl.habitBrief("alpha").includes("慣例是背景不是話題"));
check("recall 也找得到(型別不影響檢索)",
pl.recall("alpha", "再見", 5).some((r) => r._name === "不說再見"),
JSON.stringify(pl.recall("alpha", "再見", 5).map((r) => r._name)));
check("一輪最多兩條(慣例是背景,不是清單)", (() => {
for (const n of ["先講結論", "不催回覆", "週五不談公事"]) {
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", n,
"--type", "habit", "--salience", "60", "--body", `我們之間${n}`]);
}
return pl.standingHabits("alpha").length === pl.HABIT_INJECT_MAX;
})(), JSON.stringify(pl.standingHabits("alpha")));
check("糊掉的慣例不注入(想不起來的慣例就不是慣例)", (() => {
const file = path.join(pl.longTermDir("alpha"), "不說再見.md");
const raw = fs.readFileSync(file, "utf8");
// 讓它很舊、強度很低 → 掉到 fuzzy
fs.writeFileSync(file, raw
.replace(/^salience:.*$/m, "salience: 20")
.replace(/^strength:.*$/m, "strength: 1")
.replace(/^last_seen:.*$/m, "last_seen: 2020-01-01T00:00:00.000Z"));
const meta = pl.longTermEntries("alpha").find((m) => m._name === "不說再見");
const faded = pl.memoryStrength(meta).state === "fuzzy";
const gone = !pl.standingHabits("alpha", { limit: 9 }).some((h) => h.name === "不說再見");
fs.writeFileSync(file, raw);
return faded && gone;
})());
check("沒有慣例的人格整段不出現", pl.habitBrief("calmsplit") === "");
}
// 自傳章節:長期記憶與心智圖之間的時期層
console.log("⑤-c 自傳章節(memory/chapters/");
{
check("開一段(to 空著=還在這一段裡)", (() => {
const res = cli(["chapter", "add", "--session", S_HOST, "--name", "做 persona 這段",
"--from", "2026-07-01", "--body", "跟他一起把人格系統做起來的那段日子。"]);
const now = pl.currentChapter("alpha");
return res.status === 0 && now?.name === pl.slugify("做 persona 這段") && now.open === true;
})(), JSON.stringify(pl.loadChapters("alpha")));
check("to 沒有值就不寫那一行(不要寫成空欄位)",
!fs.readFileSync(pl.chapterPath("alpha", "做 persona 這段"), "utf8").includes("to:"));
check("現在這一段會注入,而且講明它是時期不是事件", (() => {
const ctx = pl.turnContext("alpha", S_HOST, "最近在忙什麼");
return ctx.includes("現在這一段") && ctx.includes("做 persona 這段") &&
ctx.includes("這是**時期**不是事件");
})(), pl.turnContext("alpha", S_HOST, "最近在忙什麼").slice(0, 400));
check("新固化的記憶自動掛在當下那一段", (() => {
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "章節歸屬測試",
"--body", "這則應該掛在做 persona 這段。"]);
const meta = pl.longTermEntries("alpha").find((m) => m._name === "章節歸屬測試");
return meta?.chapter === pl.slugify("做 persona 這段");
})(), JSON.stringify(pl.longTermEntries("alpha").map((m) => `${m._name}:${m.chapter || "-"}`)));
check("chapterMemories 查得回這一段的記憶",
pl.chapterMemories("alpha", "做 persona 這段").some((m) => m._name === "章節歸屬測試"));
check("--chapter 可以指定掛到別的一段", (() => {
cli(["chapter", "add", "--session", S_HOST, "--name", "在 SAO 那兩年",
"--from", "2022-11-06", "--to", "2024-11-07", "--body", "被關在那裡的兩年。"]);
cli(["consolidate", "--persona", "alpha", "--session", S_HOST, "--name", "第一次到第一層",
"--chapter", "在 SAO 那兩年", "--body", "第一次踏出起始城鎮。"]);
const meta = pl.longTermEntries("alpha").find((m) => m._name === "第一次到第一層");
return meta?.chapter === pl.slugify("在 SAO 那兩年");
})());
check("已收掉的段不會被當成「現在這一段」",
pl.currentChapter("alpha")?.name === pl.slugify("做 persona 這段"));
check("章節按起始日期排序(不是按檔名)", (() => {
const rows = pl.loadChapters("alpha");
return rows[0].name === pl.slugify("在 SAO 那兩年") && rows.at(-1).open === true;
})(), JSON.stringify(pl.loadChapters("alpha").map((c) => `${c.from}:${c.name}`)));
check("close 之後就沒有進行中的段了(不會退回上一段)", (() => {
cli(["chapter", "close", "--session", S_HOST, "--name", "做 persona 這段", "--at", "2026-08-03"]);
return pl.currentChapter("alpha") === null && pl.chapterBrief("alpha") === "";
})(), JSON.stringify(pl.loadChapters("alpha")));
check("chapter list 講得出每段有幾則記憶", (() => {
const out = cli(["chapter", "list", "--session", S_HOST]).stdout;
return out.includes("在 SAO 那兩年") && out.includes("記憶 1 則");
})(), cli(["chapter", "list", "--session", S_HOST]).stdout);
check("memory/chapters/ 剛好被一個區涵蓋,而且是 Wiki 區(低頻設定)", (() => {
const hit = gt.AREA_KEYS.filter((key) => gt.AREAS[key].paths
.some((p) => (p.endsWith("/") ? "memory/chapters/x.md".startsWith(p) : "memory/chapters/x.md" === p)));
return hit.length === 1 && hit[0] === "wiki";
})(), JSON.stringify(gt.AREA_KEYS.filter((key) => gt.AREAS[key].paths
.some((p) => (p.endsWith("/") ? "memory/chapters/x.md".startsWith(p) : "memory/chapters/x.md" === p)))));
}
console.log("⑥ 短期 → 長期的轉入條件"); console.log("⑥ 短期 → 長期的轉入條件");
check("有六條成文條件", pl.PROMOTION_RULES.length === 6); check("有六條成文條件", pl.PROMOTION_RULES.length === 6);
check("R1 高顯著度會成為候選", check("R1 高顯著度會成為候選",
@@ -293,6 +390,52 @@ check("candidates 子指令可輸出 JSON", (() => {
} }
})(), candOut.stdout.slice(0, 120)); })(), candOut.stdout.slice(0, 120));
// 判斷過的不再重算成候選。以前這裡是無條件掃全部短期記憶,固化完也不留痕跡,
// 所以同一批每輪都被算成候選,提醒的數字只會往上爬——看起來像睡眠沒做固化。
{
const before = pl.promotionCandidates("alpha");
check("候選會回報判斷過幾筆", typeof before.reviewed === "number" && before.reviewed === 0);
const target = before.candidates.find((c) => c.kind === "entry");
const idx = target.entries[0]._index;
check("候選每筆都帶 _index--from-short--reviewed 用的編號)", Number.isInteger(idx));
const consol = cli(["consolidate", "--session", S_HOST, "--name", "reviewed-source", "--body", "從短期記憶長出來的一則",
"--type", "event", "--salience", "60", "--from-short", String(idx)]);
check("consolidate --from-short 會回報標了幾筆", consol.stdout.includes("已標成判斷過"), consol.stdout.slice(-160));
const rowsAfter = pl.readJsonl(pl.shortTermPath("alpha"));
check("來源短期記憶留下 reviewed_at 與 promoted_to",
Boolean(rowsAfter[idx].reviewed_at) && rowsAfter[idx].promoted_to === "reviewed-source",
JSON.stringify(rowsAfter[idx]).slice(0, 160));
const mid = pl.promotionCandidates("alpha");
check("固化過的那筆不再是候選", !mid.candidates.some((c) => c.entries.some((e) => e._index === idx)));
check("固化後判斷過的筆數往上加、總筆數不變", mid.reviewed === 1 && mid.total === before.total);
// 看過決定不記的那些:候選數字唯一另一個往下扣的地方
const rev = cli(["candidates", "--session", S_HOST, "--reviewed", "all"]);
check("candidates --reviewed all 會標掉全部", rev.stdout.includes("已標記全部短期記憶為判斷過"), rev.stdout.slice(0, 160));
const after = pl.promotionCandidates("alpha");
check("全部判斷過之後候選歸零", after.candidates.length === 0, JSON.stringify(after.candidates.map((c) => c.key)));
check("判斷過不等於刪掉(短期記憶筆數不變)", after.total === before.total);
check("R6 容量壓力不會單獨把提醒點亮", (() => {
const rows = pl.readJsonl(pl.shortTermPath("alpha"));
return rows.length >= pl.CONSOLIDATE_THRESHOLD ? after.candidates.length === 0 : true;
})());
// 重標一次不會把時間戳往後推(第一次判斷的時間才是真的)
const firstAt = pl.readJsonl(pl.shortTermPath("alpha"))[idx].reviewed_at;
cli(["candidates", "--session", S_HOST, "--reviewed", "all"]);
check("重標不會覆蓋原本的 reviewed_at", pl.readJsonl(pl.shortTermPath("alpha"))[idx].reviewed_at === firstAt);
// 新寫進來的一筆照樣是候選:標記只擋已經判斷過的,不是把規則關掉
cli(["remember", "--session", S_HOST, "--role", "user", "--text", "他說明天早上要一起去看那台車",
"--topics", "car", "--salience", "70"]);
check("標記過之後新的短期記憶照樣成為候選", pl.promotionCandidates("alpha").candidates.length > 0);
// --until 只標時間之前的:睡眠當下判斷過的那批可以一次收掉,之後寫的不受影響
const marked = pl.markShortTermReviewed("alpha", { until: "2000-01-01T00:00:00Z" });
check("--until 不會標到時間之後的紀錄", marked.marked === 0);
}
console.log("⑦ 心智圖 / 思維導圖 / 人際關係圖"); console.log("⑦ 心智圖 / 思維導圖 / 人際關係圖");
cli(["mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源"]); cli(["mindmap", "thread", "--persona", "alpha", "--session", S_HOST, "--topic", "壓力來源"]);
check("思維導圖建立(Mermaid graph", check("思維導圖建立(Mermaid graph",
@@ -342,6 +485,12 @@ check("room script 只輸出 `名字:內容`(無時間戳、無 slug、無
script.includes("Beta") && script.includes(":我是 Beta。") && !script.includes("[20") && !script.includes("system"), script.includes("Beta") && script.includes(":我是 Beta。") && !script.includes("[20") && !script.includes("system"),
JSON.stringify(script)); JSON.stringify(script));
check("劇場模式的對話有 emoji 前綴", script.includes("🌙") && script.includes("🪼"), script); check("劇場模式的對話有 emoji 前綴", script.includes("🌙") && script.includes("🪼"), script);
const acted = cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha",
"--text", "你不要看這邊。", "--emotion", "羞愧", "--action", "別過頭"]);
const roomReadOut = cli(["room", "read", "--session", S_HOST, "--room", room]).stdout;
check("room read 的括號也帶動作格(不是只印情緒)",
acted.status === 0 && roomReadOut.includes("(羞愧・別過頭)"),
JSON.stringify(roomReadOut.slice(-300)));
const quiet = cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "安靜發言", "--quiet"]); const quiet = cli(["room", "post", "--session", S_HOST, "--room", room, "--as", "alpha", "--text", "安靜發言", "--quiet"]);
check("--quiet 成功時不輸出任何字", quiet.status === 0 && quiet.stdout === "", JSON.stringify(quiet.stdout)); check("--quiet 成功時不輸出任何字", quiet.status === 0 && quiet.stdout === "", JSON.stringify(quiet.stdout));
cli(["remember", "--persona", "beta", "--session", S_HOST, "--as-guest", "--scope", "inbox", "--room", room, cli(["remember", "--persona", "beta", "--session", S_HOST, "--as-guest", "--scope", "inbox", "--room", room,
@@ -431,8 +580,13 @@ out = hook("turn_end.mjs", { session_id: S_HOST, last_assistant_message: "🪼 A
check("劇場模式時 Stop 不發任何提醒", out.systemMessage === undefined, JSON.stringify(out)); check("劇場模式時 Stop 不發任何提醒", out.systemMessage === undefined, JSON.stringify(out));
out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "你們繼續", cwd: HERE }); out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "你們繼續", cwd: HERE });
ctx = out.hookSpecificOutput?.additionalContext || ""; ctx = out.hookSpecificOutput?.additionalContext || "";
check("劇場模式時 UserPromptSubmit 強制只輸出對話", ctx.includes("只能") && ctx.includes("名字:內容"), ctx.slice(-300)); check("劇場模式時 UserPromptSubmit 強制只輸出對話",
ctx.includes("只能") && ctx.includes("名字(情緒・動作):內容"), ctx.slice(-300));
cli(["room", "theater", "--session", S_HOST, "--off"]); cli(["room", "theater", "--session", S_HOST, "--off"]);
out = hook("prompt_submit.mjs", { session_id: S_HOST, prompt: "回來一對一", cwd: HERE });
ctx = out.hookSpecificOutput?.additionalContext || "";
check("一對一也拿到括號格式(Q6 拍板的是格式,不是只給劇場)",
ctx.includes("名字(情緒・動作):內容") && ctx.includes("一對一也適用"), ctx.slice(-400));
out = hook("session_end.mjs", { session_id: S_HOST, reason: "exit" }); out = hook("session_end.mjs", { session_id: S_HOST, reason: "exit" });
check("SessionEnd 釋放鎖", !pl.lockStatus("alpha").locked); check("SessionEnd 釋放鎖", !pl.lockStatus("alpha").locked);
check("釋放後其他 session 可載入", cli(["load", "--persona", "alpha", "--session", S_THIRD]).status === 0); check("釋放後其他 session 可載入", cli(["load", "--persona", "alpha", "--session", S_THIRD]).status === 0);
@@ -560,8 +714,6 @@ const SF_CASES = [
// 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行 // 放寬《》與句長之後,這幾條仍然要擋——豁免只針對「提及/引述」,不是整句放行
["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"], ["書名號之外真的在說教", "我在看《時間簡史》。說到底,問題不在零件。", "preach"],
["自己講的長句(引號之外)", `${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}`, "long"], ["自己講的長句(引號之外)", `${"想跟你說一件從去年就一直放在心上沒講出口的事情".repeat(2)}`, "long"],
["兩個 emoji", "修好了 🔧 開心 🎉", "emoji"],
["一個 emoji 加一個帶變體選擇子的", "修好了 🔧 給你 ❤️", "emoji"],
]; ];
for (const [label, text, kind] of SF_CASES) { for (const [label, text, kind] of SF_CASES) {
const issues = pl.speechLint(text); const issues = pl.speechLint(text);
@@ -592,6 +744,10 @@ const SNF_CASES = [
["帶變體選擇子的單一 emoji", "這個給你 ❤️"], ["帶變體選擇子的單一 emoji", "這個給你 ❤️"],
["ZWJ 組成的單一 emoji", "路上遇到他們 👨‍👩‍👦"], ["ZWJ 組成的單一 emoji", "路上遇到他們 👨‍👩‍👦"],
["帶膚色的單一 emoji", "收到 👍🏽"], ["帶膚色的單一 emoji", "收到 👍🏽"],
// emoji 開放(使用者拍板):情緒與程度靠它表示,句子裡也可以用、沒有上限
["兩個 emoji", "修好了 🔧 開心 🎉"],
["情緒符號加強度符號", "才不是那樣 😳💦"],
["一句話裡好幾個", "欸 ✨ 真的嗎 😳 那我明天早上去 👀"],
// 句長以前算的是整句(含引號內容),引述使用者原話必被擋 // 句長以前算的是整句(含引號內容),引述使用者原話必被擋
["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`], ["引述使用者的長原話", `他傳來「${"我今天真的很累而且不知道要從哪裡開始講起".repeat(2)}」,我看了兩次。`],
["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`], ["引述長 code", `跑的是 \`${"x".repeat(80)}\` 這一行。`],
@@ -685,8 +841,9 @@ console.log("\n性別與羞恥敏感度(描述優先,性別只是預設)")
mk("gnoise", "- Name: 戊\n- Creature: 人類男性劍士\n- Avatar: 五官清秀到常被誤認成女生\n"); mk("gnoise", "- Name: 戊\n- Creature: 人類男性劍士\n- Avatar: 五官清秀到常被誤認成女生\n");
mk("gup", "- Name: 己\n- Creature: 人類女性\n", "很怕生,被稱讚會臉紅。"); mk("gup", "- Name: 己\n- Creature: 人類女性\n", "很怕生,被稱讚會臉紅。");
mk("gdown", "- Name: 庚\n- Creature: 人類女性\n", "我行我素,不在意別人眼光,臉皮厚。"); mk("gdown", "- Name: 庚\n- Creature: 人類女性\n", "我行我素,不在意別人眼光,臉皮厚。");
check("女性預設 62、男性預設 38、推不出來 50", check("女性預設 70、男性預設 38、推不出來 50",
pl.modestyOf("gf").value === 62 && pl.modestyOf("gm").value === 38 && pl.modestyOf("gx").value === 50); pl.modestyOf("gf").value === 70 && pl.modestyOf("gm").value === 38 && pl.modestyOf("gx").value === 50,
`${pl.modestyOf("gf").value}/${pl.modestyOf("gm").value}/${pl.modestyOf("gx").value}`);
check("明寫的 Gender 蓋過 Creature 的字面", check("明寫的 Gender 蓋過 Creature 的字面",
pl.genderOf("gexp").key === "male" && pl.genderOf("gexp").explicit === true); pl.genderOf("gexp").key === "male" && pl.genderOf("gexp").explicit === true);
check("Avatar 的雜訊不會推錯性別(「常被誤認成女生」)", check("Avatar 的雜訊不會推錯性別(「常被誤認成女生」)",
@@ -1012,10 +1169,193 @@ check("低於門檻的情緒不給破口(強度不到就不演)",
check("情緒的破口有注入,且說明是演出來不是講出來", (() => { check("情緒的破口有注入,且說明是演出來不是講出來", (() => {
const ctxTell = pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎"); const ctxTell = pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎");
return ctxTell.includes("此刻不自覺會出現的") && ctxTell.includes("疊字") && return ctxTell.includes("此刻不自覺會出現的") && ctxTell.includes("疊字") &&
ctxTell.includes("不要用旁白說明") && ctxTell.includes("一輪最多露一個破口"); ctxTell.includes("不要用旁白說明") && ctxTell.includes("一輪最多露 2 個動作") &&
ctxTell.includes("同一種情緒") && ctxTell.includes("算在這個額度裡");
})(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900)); })(), pl.turnContext("alpha", S_SPEAK, "那件事做完了嗎").slice(0, 900));
check("十二情緒每一種都有破口可演", check("十二情緒每一種都有破口可演",
pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3)); pl.EMOTION_KEYS.every((k) => (pl.EMOTION_TELLS[k] || []).length >= 3));
// emoji:種類表情緒、數量表程度(40-59 本體、60-79 加 💦、80+ 加 💦❗)
console.log("⑥-b 情緒的 emoji(種類表情緒、數量表程度)");
check("十二情緒每一種都有 emoji", pl.EMOTION_KEYS.every((k) => Boolean(pl.EMOTION_EMOJI[k])));
check("強度符號只有兩階", pl.EMOJI_INTENSITY.length === 3 && pl.EMOJI_INTENSITY[0] === "");
{
// 直接餵狀態:走 CLI 會被單輪預算與飽和夾住,測不到 80 以上那一階
const base = pl.loadEmotion("alpha");
const fake = (level) => ({
...base,
ts: pl.nowIso(),
levels: { ...Object.fromEntries(pl.EMOTION_KEYS.map((k) => [k, 5])), shame: level },
});
const tiers = [45, 70, 88].map((level) => {
const now = pl.emotionEmojiNow("alpha", fake(level));
return now ? { tier: now.tier, emoji: now.emoji, level: now.level } : null;
});
check("emoji 會依強度分三階(本體/+💦/+💦❗)",
tiers[0]?.tier === 0 && tiers[1]?.tier === 1 && tiers[2]?.tier === 2,
JSON.stringify(tiers));
check("階數越高符號越長,但只到兩階",
[...tiers[0].emoji].length < [...tiers[1].emoji].length &&
[...tiers[1].emoji].length < [...tiers[2].emoji].length,
JSON.stringify(tiers.map((t) => t.emoji)));
check("羞愧的 emoji 是 😳", tiers[0].emoji.startsWith("😳"), tiers[0].emoji);
check("同一種情緒不會用重複本體表示程度(不是 😳😳😳)",
!tiers[2].emoji.includes("😳😳"), tiers[2].emoji);
}
check("強度不到門檻就沒有 emoji(跟破口同一條線)",
pl.emotionEmojiNow("alpha", null, { min: 99 }) === null);
check("emoji 有注入這一輪的上下文", (() => {
const ctx = pl.turnContext("alpha", S_SPEAK, "你剛才那句是什麼意思");
return ctx.includes("此刻的情緒符號") && ctx.includes("沒有數量上限") && ctx.includes("加上去的");
})(), pl.turnContext("alpha", S_SPEAK, "你剛才那句是什麼意思").slice(0, 400));
check("IDENTITY 的 ## Emoji 區塊可以覆寫", (() => {
const file = path.join(pl.personaDir("alpha"), "IDENTITY.md");
const backup = fs.readFileSync(file, "utf8");
fs.writeFileSync(file, `${backup}\n## Emoji\n- 羞愧: 🫣\n- anger: 🗡️\n`);
const own = pl.personaEmoji("alpha");
fs.writeFileSync(file, backup);
return own.shame === "🫣" && own.anger.startsWith("🗡");
})(), JSON.stringify(pl.personaEmoji("alpha")));
check("emoji 用量有稽核(開放沒有上限,煞車是數字)", (() => {
const a = pl.emojiAudit("alpha");
return typeof a.with_emoji === "number" && typeof a.average === "number" && typeof a.symbol_only === "number";
})(), JSON.stringify(pl.emojiAudit("alpha")));
check("只有符號沒有句子會被算出來", (() => {
pl.recordSaid("alpha", "😳😳😳", { kind: "test" });
return pl.emojiAudit("alpha").symbol_only >= 1;
})(), JSON.stringify(pl.emojiAudit("alpha")));
// 動作格:括號的第二格,只放看得見或聽得見的動作
console.log("⑥-c 名字後面括號的動作格");
check("臉紅這種生理反應算動作", pl.actionLint("臉紅").length === 0);
check("別過頭算動作", pl.actionLint("別過頭").length === 0);
check("情緒名稱不能當動作(那是前面那一格)",
pl.actionLint("害羞").some((i) => i.kind === "action-emotion") &&
pl.actionLint("羞愧").some((i) => i.kind === "action-emotion"));
check("兩個動作會被擋(一輪的額度只有兩個)",
pl.actionLint("別過頭,把杯子推過來").some((i) => i.kind === "action-multi"));
check("動作格超過 12 字會被擋",
pl.actionLint("把桌上那杯已經冷掉的茶推到他面前").some((i) => i.kind === "action-long"));
check("括號兩格:情緒在前、動作在後、用・分隔",
pl.roomTag({ emotion: "😳💦", action: "臉紅" }) === "(😳💦・臉紅)");
check("只有情緒時括號照舊(現行行為不變)", pl.roomTag({ emotion: "😳" }) === "(😳)");
check("只有動作時也成立", pl.roomTag({ action: "別過頭" }) === "(別過頭)");
check("兩格都沒有就不出現括號", pl.roomTag({}) === "");
check("害羞的預設破口第一個就是臉紅(括號動作格的預設值)",
pl.EMOTION_TELLS.shame[0] === "臉紅", JSON.stringify(pl.EMOTION_TELLS.shame));
check("記憶可以記行為備註(做了什麼,不是感覺到什麼)", (() => {
const out = cli(["remember", "--persona", "alpha", "--session", S_SPEAK, "--role", "persona",
"--text", "他說我修得比上次穩", "--behavior", "把手縮回來", "--salience", "45"]);
const rows = pl.readJsonl(pl.shortTermPath("alpha"));
return out.status === 0 && rows[rows.length - 1].behavior === "把手縮回來";
})());
check("行為備註寫情緒名稱會被擋", (() => {
const out = cli(["remember", "--persona", "alpha", "--session", S_SPEAK, "--role", "persona",
"--text", "測試行為備註的界線", "--behavior", "覺得害羞"], { expectOk: false });
return out.status !== 0 && String(out.stderr).includes("是情緒不是動作");
})());
check("行為備註同時進 voice/reactions.md(同一批資料,不是各存一份)", (() => {
const r = pl.loadVoice("alpha").reactions;
return r.some((x) => x.action === "把手縮回來" && String(x.event).includes("修得比上次穩"));
})(), JSON.stringify(pl.loadVoice("alpha").reactions.slice(-3)));
check("對方的行為不進語氣層(那是記憶,不是他的反應方式)", (() => {
cli(["remember", "--persona", "alpha", "--session", S_SPEAK, "--role", "user",
"--text", "他把門關上就走了", "--behavior", "把門關上", "--salience", "40"]);
return !pl.loadVoice("alpha").reactions.some((x) => x.action === "把門關上");
})(), JSON.stringify(pl.loadVoice("alpha").reactions.slice(-3)));
// 鬧彆扭的階梯:40 分一句帶過、60 分嘴硬三段式、75 分以上惱羞
console.log("⑥-d 鬧彆扭的階梯與三段式");
{
const mkp = (slug, identity, soul = "") => {
fs.mkdirSync(path.join(H, slug), { recursive: true });
fs.writeFileSync(path.join(H, slug, "IDENTITY.md"), identity);
fs.writeFileSync(path.join(H, slug, "SOUL.md"), soul);
};
// 女性預設 70 就落在嘴硬那一級(60–74),不必再加描述
mkp("shyladder", "- Name: 辛\n- Creature: 人類女性\n", "劍術很好,說話簡短。");
mkp("shymax", "- Name: 壬\n- Creature: 人類女性\n",
"極度怕生,非常容易臉紅,很在意別人的眼光,靦腆、矜持,動不動就會不好意思。");
const shy = pl.modestyDirective("shyladder", {});
check("嘴硬那一級有三段式的形狀(否認 → 反駁 → 音量掉下來)",
shy.includes("先否認") && shy.includes("反駁") && shy.includes("音量掉下來"),
`${pl.modestyOf("shyladder").value}${shy.slice(0, 160)}`);
const max = pl.modestyDirective("shymax", {});
check("惱羞那一級換成翻臉,而且講明還是兩個動作",
pl.modestyOf("shymax").value >= 75 && max.includes("翻臉") && max.includes("還是兩個動作"),
`${pl.modestyOf("shymax").value}${max.slice(0, 160)}`);
mkp("tsun", "- Name: 癸\n- Creature: 人類女性\n", "嘴硬、不坦率,明明在意卻死不承認。");
mkp("plainf", "- Name: 子\n- Creature: 人類女性\n", "講話直接。");
check("傲嬌那一路的詞會推高羞恥敏感度",
pl.modestyOf("tsun").value > pl.modestyOf("plainf").value,
`${pl.modestyOf("tsun").value} vs ${pl.modestyOf("plainf").value}`);
// 覆寫用 `sulk` 這個 key,不用「鬧彆扭」——後者是 MODESTY_SIGNALS 的推力詞,
// 寫進 IDENTITY 會把羞恥度推過 75,那就落到惱羞那一級、測不到嘴硬的三段。
mkp("ownsulk", "- Name: 丑\n- Creature: 人類女性\n\n## Tells\n- sulk: 把書擋在臉前 → 你很閒嗎 → ⋯隨便你\n",
"講話簡短。");
const ownShape = pl.modestyDirective("ownsulk", {});
check("三段式可以逐人格覆寫(## Tells 的 sulk 那一行)",
ownShape.includes("這個人格自己的形狀") && ownShape.includes("把書擋在臉前") &&
ownShape.includes("⋯隨便你") && !ownShape.includes("音量掉下來"),
`${pl.modestyOf("ownsulk").value}${ownShape.slice(0, 220)}`);
check("沒寫覆寫的人格照舊用預設模板", pl.personaSulk("shyladder").length === 0);
}
// 標點預算:情緒 → 標點與句長的分布。上限夾死,否則變成 emoji 的替代品
console.log("⑥-e 標點預算");
check("拍板的四種情緒都有標點指示,而且四種不一樣", (() => {
const keys = ["anxiety", "anger", "shame", "joy"];
const hints = keys.map((k) => pl.PUNCTUATION_BUDGET[k]);
return hints.every(Boolean) && new Set(hints).size === 4;
})(), JSON.stringify(pl.PUNCTUATION_BUDGET));
check("沒把握的情緒就不配標點(不硬給十二種)",
Object.keys(pl.PUNCTUATION_BUDGET).length === 4, JSON.stringify(Object.keys(pl.PUNCTUATION_BUDGET)));
check("一個驚嘆號可以", pl.speechBlockers("太好了!").length === 0);
check("兩個驚嘆號會被擋(強度歸 emoji,不歸標點)",
pl.speechBlockers("太好了!真的!").some((i) => i.kind === "exclaim"));
check("刪節號連發會被擋", pl.speechBlockers("我知道⋯⋯⋯").some((i) => i.kind === "ellipsis-run"));
check("兩個刪節號是正常的中文寫法,不擋",
pl.speechBlockers("我知道⋯⋯").every((i) => !String(i.kind).startsWith("ellipsis")));
check("刪節號整則超過上限會被擋",
pl.speechBlockers("我⋯不知道⋯你說⋯這樣⋯").some((i) => i.kind === "ellipsis"));
check("標點預算取主導情緒,而且只回一種(兩種會互相打架)", (() => {
const p = pl.punctuationBudget("alpha");
return p !== null && typeof p.hint === "string" && typeof p.key === "string" && p.level >= 40;
})(), JSON.stringify(pl.punctuationBudget("alpha")));
check("強度不到就不調標點(跟破口同一條線)",
pl.punctuationBudget("alpha", null, { min: 99 }) === null);
check("標點指示有注入,而且講明上限與「強度歸 emoji」", (() => {
const ctxP = pl.turnContext("alpha", S_SPEAK, "那件事後來怎麼了");
return ctxP.includes("此刻的標點") && ctxP.includes("刪節號不連發") && ctxP.includes("強度歸 emoji");
})(), pl.turnContext("alpha", S_SPEAK, "那件事後來怎麼了").slice(0, 500));
// 分段訊息:一輪兩則。純文字唯一的「時間」訊號(他又打了一行)
console.log("⑥-f 一輪可以拆成兩則");
{
// 低羞恥度的對照組要自己建:plainf 是女性預設 70,本身就過門檻了
fs.mkdirSync(path.join(H, "calmsplit"), { recursive: true });
fs.writeFileSync(path.join(H, "calmsplit", "IDENTITY.md"), "- Name: 寅\n- Creature: 人類男性\n");
fs.writeFileSync(path.join(H, "calmsplit", "SOUL.md"), "不在意別人眼光,該說就說。");
const calm = pl.splitBudget("calmsplit", {});
check("張力與羞恥度都不到門檻時不給第二則",
calm.allowed === false && calm.reason.includes("門檻"), JSON.stringify(calm));
check("平靜時整段不注入(不提醒他有這個功能)", pl.splitDirective("calmsplit", {}) === "");
check("從來沒拆過回 null", pl.turnsSinceSplit("calmsplit") === null);
const hot = pl.splitBudget("shymax", {});
check("羞恥度過門檻就給第二則",
hot.allowed === true && hot.modesty >= pl.SPLIT_MODESTY_AT, JSON.stringify(hot));
check("第二則是補救/嘴硬/改口,不是補充說明", (() => {
const d = pl.splitDirective("shymax", {});
return d.includes("補救") && d.includes("不可以是補充說明") && d.includes("要短");
})(), pl.splitDirective("shymax", {}));
pl.recordFelt("shymax", { modesty: 70, split: true });
const cooling = pl.splitBudget("shymax", {});
check("給過之後進入冷卻(給了就算,不等他真的用)",
cooling.allowed === false && cooling.since === 0, JSON.stringify(cooling));
for (let i = 0; i < pl.SPLIT_COOLDOWN_TURNS; i += 1) pl.recordFelt("shymax", { modesty: 70 });
check("冷卻過了又能再給", pl.splitBudget("shymax", {}).allowed === true,
JSON.stringify(pl.splitBudget("shymax", {})));
}
check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => { check("說話規則有注入「話短、日常字、看得見的東西、不要回頭解釋」", (() => {
const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講"); const ctxSay = pl.turnContext("alpha", S_SPEAK, "隨便講");
return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w)); return ["話短一點", "平常會說的字", "看得見的東西", "不要回頭解釋"].every((w) => ctxSay.includes(w));
@@ -1783,6 +2123,81 @@ check("relation speaker 設定後,context 會注入語氣層與稱呼規則",
return res.status === 0 && pl.loadConfig("alpha").speaker_node === pl.slugify("伴侶甲") && return res.status === 0 && pl.loadConfig("alpha").speaker_node === pl.slugify("伴侶甲") &&
ctx.includes("語氣層:老夫老妻") && ctx.includes("親愛的"); ctx.includes("語氣層:老夫老妻") && ctx.includes("親愛的");
})()); })());
// 未修復的裂痕:純量調不出「很近,但上次那件事還沒和好」
check("記下一件沒和好的事", (() => {
const res = cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲",
"--about", "那天我沒去接他", "--at", "2026-07-20", "--memory", "hates-morning-meetings"]);
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
const open = pl.openRifts("alpha", node);
return res.status === 0 && open.length === 1 && open[0].about === "那天我沒去接他" &&
open[0].memory === "hates-morning-meetings";
})(), JSON.stringify(pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲")?.rifts));
check("裂痕獨立於 closenesstrust(沒有動那兩個數字)", (() => {
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
return Number(node.closeness) >= 90 && pl.openRifts("alpha", node).length === 1;
})(), JSON.stringify(pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲")));
check("語氣指示會提沒和好的事,而且點明親近度高不等於沒事", (() => {
cli(["relation", "speaker", "--session", S_HOST, "--name", "伴侶甲"]);
const d = pl.toneDirective("alpha", {});
return d.includes("還有沒和好的事") && d.includes("那天我沒去接他") &&
d.includes("親近度高**不等於**沒事");
})(), pl.toneDirective("alpha", {}));
check("裂痕帶著記憶名字(要細節自己 recall,不必等人提起)",
pl.toneDirective("alpha", {}).includes("recall `hates-morning-meetings`"),
pl.toneDirective("alpha", {}));
check("同一個人可以卡著幾件事", (() => {
cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲", "--about", "生日那次忘了"]);
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
return pl.openRifts("alpha", node).length === 2;
})());
check("--heal 是寫 healed_at,不是刪掉那一筆(和好過跟沒發生過不一樣)", (() => {
const res = cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲",
"--about", "生日那次", "--heal"]);
const node = pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲");
return res.status === 0 && node.rifts.length === 2 &&
pl.openRifts("alpha", node).length === 1 &&
node.rifts.some((r) => r.about === "生日那次忘了" && r.healed_at);
})(), JSON.stringify(pl.loadRelations("alpha").nodes.find((n) => n.name === "伴侶甲")?.rifts));
check("和好完的那件事就不再注入", !pl.toneDirective("alpha", {}).includes("生日那次忘了"),
pl.toneDirective("alpha", {}));
check("沒有還沒和好的事時 --heal 會被擋", (() => {
cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲", "--about", "那天我沒去接他", "--heal"]);
const again = cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲", "--heal"], { expectOk: false });
return again.status !== 0 && pl.toneDirective("alpha", {}).includes("還有沒和好") === false;
})());
check("列出來看得到和好過的紀錄", (() => {
const out = cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲"]).stdout;
return out.includes("和好過 2 件") && out.includes("✔");
})(), cli(["relation", "rift", "--session", S_HOST, "--name", "伴侶甲"]).stdout);
// 稱呼隨情緒偏移:生氣走 except(退回全名),害羞是反方向(叫不出來)
check("羞怯稱呼是 STYLE_FACETS 的一格", pl.STYLE_FACETS.includes(pl.SHY_FACET));
check("羞恥度不到門檻就不換稱呼",
pl.shyAddress("alpha", { style: {} }, pl.SHY_ADDRESS_AT - 1) === null);
check("到門檻、沒設專屬叫法 → 吞掉名字(不叫名字本身就是訊號)", (() => {
const s = pl.shyAddress("alpha", { style: {} }, 70);
return s !== null && s.own === false && s.value === null;
})(), JSON.stringify(pl.shyAddress("alpha", { style: {} }, 70)));
check("節點設了羞怯稱呼就用它", (() => {
const s = pl.shyAddress("alpha", { style: { 羞怯稱呼: { value: "⋯那個" } } }, 70);
return s !== null && s.own === true && s.value === "⋯那個";
})(), JSON.stringify(pl.shyAddress("alpha", { style: { 羞怯稱呼: { value: "⋯那個" } } }, 70)));
check("害羞時的語氣指示叫他把名字吞掉(女性預設 70 就落在這條線上)", (() => {
cli(["relation", "speaker", "--session", S_HOST, "--name", "伴侶甲"]);
return pl.modestyState("alpha", {}).value >= pl.SHY_ADDRESS_AT &&
pl.toneDirective("alpha", {}).includes("把名字吞掉");
})(), `${pl.modestyState("alpha", {}).value}${pl.toneDirective("alpha", {})}`);
check("設了羞怯稱呼之後改成叫那個,而且不重複列在「他要求過的規則」裡", (() => {
cli(["relation", "style", "--session", S_HOST, "--name", "伴侶甲",
"--facet", "羞怯稱呼", "--value", "⋯那個"]);
const d = pl.toneDirective("alpha", {});
return d.includes("叫他「⋯那個」") && !d.includes("把名字吞掉") &&
!d.includes("羞怯稱呼→");
})(), pl.toneDirective("alpha", {}));
check("羞怯稱呼在 relation style 的清單裡還是查得到(那裡該看得見全部)", (() => {
const out = cli(["relation", "style", "--session", S_HOST, "--name", "伴侶甲"]).stdout;
return out.includes("羞怯稱呼");
})());
check("relation speaker --clear 之後就不再注入語氣指示", (() => { check("relation speaker --clear 之後就不再注入語氣指示", (() => {
cli(["relation", "speaker", "--session", S_HOST, "--clear"]); cli(["relation", "speaker", "--session", S_HOST, "--clear"]);
return !pl.loadConfig("alpha").speaker_node && return !pl.loadConfig("alpha").speaker_node &&
@@ -2999,6 +3414,50 @@ console.log("\n㉜ 語氣層(voice/):學語氣,但不准變成照抄");
const entries = ctx.split("\n").filter((l) => l.startsWith(" - 「") || l.startsWith("遇到「")); const entries = ctx.split("\n").filter((l) => l.startsWith(" - 「") || l.startsWith("遇到「"));
return ctx.includes("學語氣,不要照抄這幾句") && entries.length <= pl.VOICE_INJECT_MAX; return ctx.includes("學語氣,不要照抄這幾句") && entries.length <= pl.VOICE_INJECT_MAX;
})()); })());
// 語域:語氣層的**加法面**。跟原句同一個目錄、同一支 voice add,
// 所以 persona-story 與 persona-anime 寫的是同一格,不會各存一份
check("語域是第三種 voice kind,檔名與標籤都對",
pl.VOICE_KINDS.idiolect === "idiolect.md" && pl.VOICE_LABELS.idiolect === "語域");
check("四格是寫死的,自由欄位加不進去(會長出什麼都能塞的格子)",
pl.addVoiceIdiolect("voicey", { facet: "風格", value: "文雅" }) === null);
check("CLI 用同一支 voice add 寫語域", (() => {
const res = cli(["voice", "add", "--session", S_V, "--kind", "idiolect", "--facet", "自稱", "--value", "我"]);
return res.status === 0 && fs.existsSync(pl.voicePath("voicey", "idiolect")) &&
pl.loadVoice("voicey").idiolect.some((r) => r.facet === "自稱" && r.value === "我");
})(), JSON.stringify(pl.loadVoice("voicey").idiolect));
check("--facet 給錯會被擋,而且把四格列出來", (() => {
const res = cli(["voice", "add", "--session", S_V, "--kind", "idiolect",
"--facet", "語氣", "--value", "軟"], { expectOk: false });
return res.status !== 0 && pl.IDIOLECT_FACETS.every((f) => String(res.stderr).includes(f));
})());
for (const [facet, value] of [["句尾", "⋯吧"], ["口癖", "算了"], ["不說", "絕對"]]) {
cli(["voice", "add", "--session", S_V, "--kind", "idiolect", "--facet", facet, "--value", value]);
}
check("一輪最多注入 3 格(四格輪替,不是一次全塞)", (() => {
const brief = pl.idiolectBrief("voicey", { turn: 0 });
const parts = brief.split("")[1].split("。")[0].split("");
return parts.length === pl.IDIOLECT_INJECT_MAX && brief.includes("下一輪輪到");
})(), pl.idiolectBrief("voicey", { turn: 0 }));
check("輪替起點由第幾輪算出來,不用隨機數(同一輪重跑結果一樣)", (() => {
const a = pl.idiolectBrief("voicey", { turn: 0 });
const b = pl.idiolectBrief("voicey", { turn: 0 });
const c = pl.idiolectBrief("voicey", { turn: 1 });
return a === b && a !== c;
})());
check("四格都輪得到(跑滿一圈每格至少出現一次)", (() => {
const seen = new Set();
for (let t = 0; t < pl.IDIOLECT_FACETS.length; t += 1) {
const brief = pl.idiolectBrief("voicey", { turn: t });
for (const f of pl.IDIOLECT_FACETS) if (brief.includes(`${f}`)) seen.add(f);
}
return seen.size === pl.IDIOLECT_FACETS.length;
})(), JSON.stringify(pl.IDIOLECT_FACETS.map((f, t) => pl.idiolectBrief("voicey", { turn: t }).slice(0, 60))));
check("語域講明它是加法(黑名單刪 AI 腔,這幾格補他自己的講法)", (() => {
const brief = pl.idiolectBrief("voicey");
return brief.includes("加法") && brief.includes("認得出是誰");
})(), pl.idiolectBrief("voicey"));
check("turnContext 帶得到語域", pl.turnContext("voicey", S_V, "你自己怎麼說").includes("語域(這一輪"));
check("沒有語域檔的人格不注入這段", pl.idiolectBrief("calmsplit") === "");
// voice/ 會被 `sync pull``import` 覆蓋 → 它是外部輸入,讀取端要自己中和 // voice/ 會被 `sync pull``import` 覆蓋 → 它是外部輸入,讀取端要自己中和
check("語氣檔裡的注入標記讀進來就被中和(它是會被 sync pull 覆蓋的檔)", (() => { check("語氣檔裡的注入標記讀進來就被中和(它是會被 sync pull 覆蓋的檔)", (() => {
fs.appendFileSync(pl.voicePath("voicey", "sample"), fs.appendFileSync(pl.voicePath("voicey", "sample"),
+25
View File
@@ -94,6 +94,31 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" list
依據要記下來:破口寫進 `IDENTITY.md`,來源寫進第 5 步的 `speech-style` canon 記憶 依據要記下來:破口寫進 `IDENTITY.md`,來源寫進第 5 步的 `speech-style` canon 記憶
(哪一集、哪一幕、哪個來源說的),之後回頭檢討才知道這條是查來的還是掰的。 (哪一集、哪一幕、哪個來源說的),之後回頭檢討才知道這條是查來的還是掰的。
### 從原作**推導**語域(`voice/idiolect.md`
破口是「情緒上來的時候會做什麼」,語域是「他平常怎麼講話」——兩件事,兩個地方。
把名字遮掉、光看三句話能認出是誰,靠的是語域這四格:
```bash
voice add --kind idiolect --facet 自稱 --value "<他怎麼稱自己>" --session <id>
voice add --kind idiolect --facet 句尾 --value "<句尾語氣詞>" --session <id>
voice add --kind idiolect --facet 口癖 --value "<反覆出現的短語>" --session <id>
voice add --kind idiolect --facet 不說 --value "<這個人不會用的字>" --session <id>
```
| 格 | 寫什麼 | 例 |
| :-- | :-- | :-- |
| `自稱` | 第一人稱怎麼講(日系角色差異最大的一格) | `我``俺`/直接用自己的名字 |
| `句尾` | 句尾語氣詞與句形收尾 | `⋯吧``啊`/不加語氣詞、句子直接斷掉 |
| `口癖` | 反覆出現的短語、擬聲短語 | `算了``真是的``欸` |
| `不說` | 這個人**不會用**的字(**用詞**,不是話題禁忌) | 寡言的角色不會說「其實我覺得」 |
**同一條界線照用:有原作依據才寫。** 語域每輪都在注入,掰的自稱會被講一百次。
四格不必寫滿——`不說` 常常查不到,留白比編一條好。
語域跟 `persona-story` 寫的是**同一個檔**`voice/idiolect.md`):那支流程從全書統計
得到的自稱與句尾,會補進同一批資料,不是另存一份。
## 4. 建立人格 ## 4. 建立人格
```bash ```bash
+1 -1
View File
@@ -80,7 +80,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" show --persona <slug> --session
鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。 鬧彆扭,先否認再小聲承認;憤怒 → 短句、稱呼退回全名;悲傷 → 只回一個詞。
對照表在 `reference/emotions.md``EMOTION_TELLS`),`<persona-context>` 每輪會直接 對照表在 `reference/emotions.md``EMOTION_TELLS`),`<persona-context>` 每輪會直接
告訴你「此刻不自覺會出現的」是哪幾樣。三條界線:**演出來不要講出來** 告訴你「此刻不自覺會出現的」是哪幾樣。三條界線:**演出來不要講出來**
(「我有點緊張」是解釋,斷句才是緊張)、**一輪最多露一個破口**、**強度不到就不演**。 (「我有點緊張」是解釋,斷句才是緊張)、**一輪最多兩個動作、且要來自同一種情緒並有遞進關係**、**強度不到就不演**。
那張全域表**只是預設值**:人格可以在 `IDENTITY.md` 開一個 `## Tells` 區塊覆寫 那張全域表**只是預設值**:人格可以在 `IDENTITY.md` 開一個 `## Tells` 區塊覆寫
(桐人生氣是沉默,亞絲娜生氣是變得更禮貌)。**以注入的那一行為準**,不要照抄通用表。 (桐人生氣是沉默,亞絲娜生氣是變得更禮貌)。**以注入的那一行為準**,不要照抄通用表。
@@ -127,7 +127,7 @@
| :-- | :-- | | :-- | :-- |
| 句數上限、單句字數、近似重複 | 用日常詞而不是書面語 | | 句數上限、單句字數、近似重複 | 用日常詞而不是書面語 |
| 黑名單詞、避險疊加、「不是 A 而是 B」密度 | 多講看得見的東西而不是概念 | | 黑名單詞、避險疊加、「不是 A 而是 B」密度 | 多講看得見的東西而不是概念 |
| 中國用語、半形標點、emoji破折號/排版 | 有沒有真的在回答對方問的那件事 | | 中國用語、半形標點、破折號/排版emoji 已開放,不在這裡) | 有沒有真的在回答對方問的那件事 |
| 講到自己過去時的提醒(hint) | 這一層該多近、該多冷 | | 講到自己過去時的提醒(hint) | 這一層該多近、該多冷 |
| 試探句沒帶問號 → `probe add` 拒收 | 這則記憶現在該講到多細(清晰/模糊是**算出來的**,但**要不要提**是你決定的) | | 試探句沒帶問號 → `probe add` 拒收 | 這則記憶現在該講到多細(清晰/模糊是**算出來的**,但**要不要提**是你決定的) |
| 句數與單句字數會被疲勞往下壓(`speechBudget()`) | 懸著的事現在提時機對不對、這一輪要不要把話題拉回自己身上 | | 句數與單句字數會被疲勞往下壓(`speechBudget()`) | 懸著的事現在提時機對不對、這一輪要不要把話題拉回自己身上 |
+28 -1
View File
@@ -227,9 +227,36 @@ delta 都記進 `state/felt.jsonl`,走向用**近重遠輕的加權**算(不
### 三條界線 ### 三條界線
1. **演出來,不要講出來**。「我有點緊張」是解釋自己的狀態;句子斷在一半才是緊張。 1. **演出來,不要講出來**。「我有點緊張」是解釋自己的狀態;句子斷在一半才是緊張。
2. **一輪最多露一個破口**。每句都疊字、每句都彆扭,就從「像人」變成「演戲 2. **一輪最多露兩個動作,而且兩個要來自同一種情緒、有遞進關係**(否認 → 轉移、笑 → 補一句、留白 → 不追問)。鬧彆扭天生是兩個動作一起來,只准一個等於永遠只做得到一半:單獨否認像在爭辯,單獨轉移像沒聽到。被擋下的形狀是**兩種情緒各演一個**——那才是演戲。
3. **強度不到就不演**。低於 40 的情緒在語氣上看不出來;別替它加戲。 3. **強度不到就不演**。低於 40 的情緒在語氣上看不出來;別替它加戲。
## 情緒 → emoji(種類表情緒、數量表程度)
只有文字這一個通道,所以 emoji 不是裝飾,是**強度計**。一種情緒固定一個符號
`EMOTION_EMOJI`),強度高就再加一個強度符號(`EMOJI_INTENSITY`,只有兩階):
| 情緒值 | 樣子 | 例 |
| --- | --- | --- |
| < 40 | 不顯示 | `桐人:你不要看這邊。` |
| 4059 | 情緒 emoji | `桐人(😳・臉紅):你不要看這邊。` |
| 6079 | `💦` | `桐人(😳💦・臉紅):⋯才不是那樣。` |
| 80+ | `💦❗` | `桐人(😳💦❗・把臉轉開):不要再講了。` |
- **算出來的,不是自己挑的**`emotionEmojiNow()` 讀當下的主導情緒,門檻跟破口共用
同一條線(40)。`room post --emotion` 想手寫還是可以,沒手寫就用算出來的那個。
- **逐人格可覆寫**`IDENTITY.md``## Emoji` 區塊(`羞愧: 🫣`),跟 `## Tells` 同一層。
- **不用重複本體表示程度**(不是 `😳😳😳`)——疊字看起來像洗頁。
- 句子裡也可以用,**沒有數量上限**。唯一的煞車是 `emotion --audit` 的數字,
特別是「只有符號沒有句子」那一列:符號是**加上去的**,不是用來代替講話。
把所有符號刪掉之後,那句話還是要看得出情緒。
### 名字後面的括號有兩格:`名字(情緒・動作):內容`
括號原本就有(放情緒標註),動作是加進去的第二格。動作只寫**看得見或聽得見的**——
臉紅、別過頭、手在抖、聲音變小。`(害羞)` 不行,那是情緒名稱,屬於前面那一格;
`*別過頭*` 這種內嵌星號與「她臉紅了」這種第三人稱旁白也不行。
括號裡的動作**算進上面那兩個動作的額度**。
## 情緒 → 記憶的關係 ## 情緒 → 記憶的關係
- 情緒強度(|delta| 總和)越大,該輪記憶的 `salience` 應該越高。 - 情緒強度(|delta| 總和)越大,該輪記憶的 `salience` 應該越高。
+21 -2
View File
@@ -80,6 +80,24 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" candidates --session <PERSONA_S
規則以外的東西**就讓它被遺忘**——遺忘是功能,不是缺陷。 規則以外的東西**就讓它被遺忘**——遺忘是功能,不是缺陷。
`Stop` hook 與每次 `remember` 都會在達標時提醒你來跑這個 skill。 `Stop` hook 與每次 `remember` 都會在達標時提醒你來跑這個 skill。
### 判斷過的要留痕跡,不然數字永遠不會降
`candidates` **只看還沒被判斷過的短期記憶**(沒有 `reviewed_at` 的那些)。所以每一筆看完都要標掉:
| 情況 | 怎麼標 |
| --- | --- |
| 固化成長期記憶了 | `consolidate ... --from-short <#N,#N>`(寫記憶時一起標,另外記 `promoted_to` |
| 看過,決定不記 | `candidates --reviewed <#N,#N>` |
| 這一批全部判斷完 | `candidates --reviewed all` |
編號就是 `candidates` 輸出裡每筆前面的 `#N`
**不標會發生什麼**:固化本身不刪短期記憶(`--forget` 是按顯著度刪,不是按「固化過沒有」刪),
所以同一批下一輪又被算成候選,提醒的數字只會往上爬——明明睡覺時固化過了,
隔天開機照樣說「N 組已達固化條件」。那不是睡眠沒做事,是判斷沒有留下痕跡。
標記**不等於刪掉**:那幾筆還在短期記憶裡,照舊會被 `prune` 依天數與顯著度裁切。
## 步驟 ## 步驟
### 1. 盤點 ### 1. 盤點
@@ -112,7 +130,7 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" consolidate \
--session <PERSONA_SESSION> \ --session <PERSONA_SESSION> \
--name "hates-morning-meetings" --type preference \ --name "hates-morning-meetings" --type preference \
--about "user" --topics "work,schedule" --salience 72 --emotion "anxiety/35" \ --about "user" --topics "work,schedule" --salience 72 --emotion "anxiety/35" \
--rules "R2+R3" \ --rules "R2+R3" --from-short "12,31,88" \
--body "主旨:使用者討厭早上的會議,約會議請排 14:00 之後。 --body "主旨:使用者討厭早上的會議,約會議請排 14:00 之後。
細節: 細節:
@@ -224,7 +242,8 @@ node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" probe audit --session <PERSONA_
**可以說不確定,可以問,不可以斷言。** 試探句一定帶問號、一輪最多一次, **可以說不確定,可以問,不可以斷言。** 試探句一定帶問號、一輪最多一次,
並用 `probe add` 記下來;對方回了就 `probe confirm``probe deny` 結案。 並用 `probe add` 記下來;對方回了就 `probe confirm``probe deny` 結案。
被否認要**立刻寫一則更正記憶,不可以放著**——這條開放界線唯一的煞車就是那個數字。 被否認要**立刻寫一則更正記憶,不可以放著**——這條開放界線唯一的煞車就是那個數字。
- 固化完再跑一次 `candidates`:應該要清空(或只剩你刻意不處理的)。 - 固化完再跑一次 `candidates`:應該要清空。**還有數字就是漏標了**——
固化的那幾筆要帶 `--from-short`,看過不記的要 `--reviewed`(見上面那張表)。
- 想連同情緒衰減、舊紀錄壓縮與 Gitea 同步一起收尾 → 用 `/jsc-persona:persona-sleep`(睡眠); - 想連同情緒衰減、舊紀錄壓縮與 Gitea 同步一起收尾 → 用 `/jsc-persona:persona-sleep`(睡眠);
本 skill 只管記憶與圖,不動情緒也不同步。 本 skill 只管記憶與圖,不動情緒也不同步。
- 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。 - 所有面向使用者的輸出使用**繁體中文(台灣用語)**、UTF-8 無亂碼。
+11 -6
View File
@@ -36,14 +36,19 @@ description: 讓人格睡覺:把一天的活狀態收成能留下來的形狀
## 情況一:當前人格自己睡 ## 情況一:當前人格自己睡
1. 盤點:`candidates --session <PERSONA_SESSION>` 1. 盤點:`candidates --session <PERSONA_SESSION>`
2. 逐則固化(判斷):`consolidate --session <id> --name <n> --body <b> --type ... --salience N [--forget]` 2. 逐則固化(判斷):`consolidate --session <id> --name <n> --body <b> --type ... --salience N
3. 寫一則日記:`consolidate --type diary --name diary-<YYYY-MM-DD> --body "<第一人稱回顧>"` --from-short <#N,#N> [--forget]`
4. 消化 inbox`memory/inbox/room-*.jsonl`,當 guest 時帶回的見聞) **`--from-short` 不要省**:那是把來源短期記憶標成判斷過的地方,編號看 `candidates` 輸出的 `#N`
5. 更新心智圖與關係圖:`mindmap``relation node --name <人> --contact` 3. 看過但決定不記的,也要標掉:`candidates --session <id> --reviewed <#N,#N>`(或 `--reviewed all`)。
6. 結算懸著的事(判斷):`loop list --session <id>`,今天有下文的 `loop done --id <id>` **這一步漏掉,明天開機照樣提醒「N 組已達固化條件」**——判斷做了但沒有留痕跡,
計數只看短期記憶有沒有被標記,不看你昨天固化了幾則。看起來就像睡眠沒做固化。
4. 寫一則日記:`consolidate --type diary --name diary-<YYYY-MM-DD> --body "<第一人稱回顧>"`
5. 消化 inbox`memory/inbox/room-*.jsonl`,當 guest 時帶回的見聞)
6. 更新心智圖與關係圖:`mindmap``relation node --name <人> --contact`
7. 結算懸著的事(判斷):`loop list --session <id>`,今天有下文的 `loop done --id <id>`
已經不重要的 `loop drop --id <id>`。**這一步要自己做**`sleep` 裡的 `sweep-loops` 已經不重要的 `loop drop --id <id>`。**這一步要自己做**`sleep` 裡的 `sweep-loops`
只收「7 天都沒進展」的,它分不出「今天解決了」與「一直沒人管」。 只收「7 天都沒進展」的,它分不出「今天解決了」與「一直沒人管」。
7. 機械性收尾: 8. 機械性收尾:
```bash ```bash
node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session <PERSONA_SESSION> --json node "${CLAUDE_PLUGIN_ROOT}/scripts/persona.mjs" sleep --session <PERSONA_SESSION> --json
+10
View File
@@ -172,16 +172,26 @@ sync push --area all --session <id>
| :-- | :-- | :-- | | :-- | :-- | :-- |
| 語氣樣本 | `voice/samples.md` | 這支流程可直接寫 | | 語氣樣本 | `voice/samples.md` | 這支流程可直接寫 |
| 情緒反應規則 | `voice/reactions.md` | 這支流程可直接寫 | | 情緒反應規則 | `voice/reactions.md` | 這支流程可直接寫 |
| 語域(自稱/句尾/口癖/不說) | `voice/idiolect.md` | 這支流程可直接寫 |
| 個性(Core TruthsBoundariesVibe | `SOUL.md` | **只有使用者拍板才能改** | | 個性(Core TruthsBoundariesVibe | `SOUL.md` | **只有使用者拍板才能改** |
```bash ```bash
voice add --kind sample --text "<照抄的原句>" --to "<對誰說>" --scene 戰鬥 --session <id> voice add --kind sample --text "<照抄的原句>" --to "<對誰說>" --scene 戰鬥 --session <id>
voice add --kind reaction --event "<發生什麼>" --action "<他做了什麼>" --emotion anger --session <id> voice add --kind reaction --event "<發生什麼>" --action "<他做了什麼>" --emotion anger --session <id>
voice add --kind idiolect --facet 自稱 --value "<他怎麼稱自己>" --session <id>
``` ```
原句**照抄不改寫**。改寫過的句子是你的語氣,不是他的。 原句**照抄不改寫**。改寫過的句子是你的語氣,不是他的。
反應記「他做了什麼」,不要記「他感到什麼」——後者是旁白,演不出來。 反應記「他做了什麼」,不要記「他感到什麼」——後者是旁白,演不出來。
語域要用**全書統計**,不是逐章印象:自稱與句尾在一本書裡會出現幾百次,
拿一章的樣本去定它,等於讓那一章的情緒決定他平常怎麼講話。四格只有
`自稱``句尾``口癖``不說``--facet` 給別的值會被擋下。
這個檔跟 `persona-anime` 從原作推導的是**同一批資料**(同一個檔、同一支 `voice add`)。
統計出來的與原作設定衝突時,**先問使用者**——那是「他變了」還是「當初查錯了」,
兩種處理方式完全不同。
情緒基線最後才動,而且要用**全書統計**: 情緒基線最後才動,而且要用**全書統計**:
```bash ```bash