nx_lorebook.nx source
↩ module page · 202 lines · 10327 B
1// nx_lorebook.nx -- THE LOREBOOK PLANE (companionchat CC3): keyed world entries as DATA, injected into the chat turn ONLY when
2// the turn touches one of an entry's trigger terms, under an insertion budget that REFUSES LOUDLY instead of truncating.
3// Rows (knowledge/lorebook.conf -- daemon cwd first, then the nishihost tree, the gen_chat_budget.conf idiom):
4// key|trigger1, trigger2, ...|entry text (# comments and blank lines skipped)
5// @budget N (total bytes the [WORLD] block may carry, header included)
6// A row with fewer than three fields is skipped and COUNTED as malformed, never guessed at. Matching is an ASCII
7// case-insensitive substring of a trimmed trigger inside the user turn (non-ASCII case folding is deliberately not done --
8// a documented imprecision, not an accident); an entry fires at most once per turn however many of its triggers match; a
9// turn that touches no trigger writes ZERO bytes (the neg-control). When the fired entries would exceed the budget, NOTHING
10// is injected partially: out carries one refusal line naming the bytes wanted and the budget, and stats[LB_ST_REFUSED]=1, so
11// the persona is never silently cut and the caller can log the refusal rather than the model reading half a fact.
12// Contract symbol: em_lorebook (the companionchat.matrix CC3 watch). Composed by nx_companion_mind into the chat turn.
13// stats[]: LB_ST_ROWS parsed rows, LB_ST_MALFORMED, LB_ST_FIRED, LB_ST_WANTED bytes, LB_ST_BUDGET effective, LB_ST_REFUSED 0/1.
14// Gate: nx_lorebook_gate (fixture confs under /tmp/<gate>/, written at run time). license_tier: ORIGINAL
15import "nx_syscalls.nx"
16
17const LB_CONF_REL: *u8 = "knowledge/lorebook.conf" as *u8
18const LB_CONF_ABS: *u8 = "/volume1/homes/elderwesto/nishihost/knowledge/lorebook.conf" as *u8
19// the default block budget when the conf carries no @budget row: a quarter of the composed persona's 16 KiB prompt buffer
20// is the most a side-plane may take from the model's window before it crowds out the state and memory blocks
21const LB_BUDGET_DEFAULT: i64 = 4096
22const LB_STATS_N: i64 = 8
23const LB_ST_ROWS: i64 = 0
24const LB_ST_MALFORMED: i64 = 1
25const LB_ST_FIRED: i64 = 2
26const LB_ST_WANTED: i64 = 3
27const LB_ST_BUDGET: i64 = 4
28const LB_ST_REFUSED: i64 = 5
29// the refusal line must fit; below this the caller's buffer cannot even carry the refusal, so nothing is written
30const LB_REFUSE_MIN_CAP: i64 = 256
31const LB_ACC_HEADROOM: i64 = 4096
32const LB_NUM_CAP: i64 = 24
33const LB_DEC_BASE: i64 = 10
34const LB_CH_NL: i64 = 10
35const LB_CH_CR: i64 = 13
36const LB_CH_SPACE: i64 = 32
37const LB_CH_HASH: i64 = 35
38const LB_CH_AT: i64 = 64
39const LB_CH_PIPE: i64 = 124
40const LB_CH_COMMA: i64 = 44
41const LB_CH_0: i64 = 48
42const LB_CH_9: i64 = 57
43const LB_CH_A_UP: i64 = 65
44const LB_CH_Z_UP: i64 = 90
45const LB_CASE_DELTA: i64 = 32
46const LB_BUDGET_KEY: *u8 = "@budget" as *u8
47const LB_HEAD: *u8 = "[WORLD -- facts about the world this conversation just touched; treat them as true and use the exact detail]\n" as *u8
48const LB_ITEM: *u8 = "- " as *u8
49const LB_SEP: *u8 = ": " as *u8
50const LB_REFUSE_HEAD: *u8 = "[WORLD refused: the entries this turn touched need " as *u8
51const LB_REFUSE_MID: *u8 = " bytes against a lorebook budget of " as *u8
52const LB_REFUSE_TAIL: *u8 = " -- raise @budget in lorebook.conf or split the entries; nothing was injected partially]\n" as *u8
53
54func lb_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
55func lb_cat(dst: *u8, off: i64, s: *u8) -> i64 { var o: i64 = off; var i: i64 = 0; while s[i] != (0 as u8) { dst[o] = s[i]; o = o + 1; i = i + 1 } return o }
56func lb_catn(dst: *u8, off: i64, src: *u8, n: i64) -> i64 { var o: i64 = off; var i: i64 = 0; while i < n { dst[o] = src[i]; o = o + 1; i = i + 1 } return o }
57func lb_itoa(dst: *u8, off: i64, v: i64) -> i64 {
58 let t: *u8 = sys_mmap(LB_NUM_CAP); var m: i64 = v; var k: i64 = 0
59 if m == 0 { t[0] = LB_CH_0 as u8; k = 1 }
60 while m > 0 { t[k] = (LB_CH_0 + (m % LB_DEC_BASE)) as u8; m = m / LB_DEC_BASE; k = k + 1 }
61 var o: i64 = off
62 while k > 0 { k = k - 1; dst[o] = t[k]; o = o + 1 }
63 return o
64}
65func lb_lower(c: i64) -> i64 { if c >= LB_CH_A_UP { if c <= LB_CH_Z_UP { return c + LB_CASE_DELTA } } return c }
66
67// ASCII case-insensitive: first index of needle[0..nn) in hay[0..hn), or -1. Flag-exit loops, never a clobbered cursor.
68func lb_ci_find(hay: *u8, hn: i64, needle: *u8, nn: i64) -> i64 {
69 if nn <= 0 { return 0 - 1 }
70 var i: i64 = 0
71 while i + nn <= hn {
72 var j: i64 = 0
73 var same: i64 = 1
74 var scan: i64 = 1
75 while scan == 1 {
76 if j >= nn { scan = 0 } else {
77 if lb_lower(hay[i + j] as i64) != lb_lower(needle[j] as i64) { same = 0; scan = 0 } else { j = j + 1 }
78 }
79 }
80 if same == 1 { return i }
81 i = i + 1
82 }
83 return 0 - 1
84}
85
86// 1 when any comma-separated, space-trimmed trigger in trig[0..tn) occurs in the turn; 0 otherwise. Empty triggers never match.
87func lb_triggers_hit(trig: *u8, tn: i64, umsg: *u8, umlen: i64) -> i64 {
88 var hit: i64 = 0
89 var s: i64 = 0
90 var i: i64 = 0
91 while i <= tn {
92 var atend: i64 = 0
93 if i == tn { atend = 1 } else { if (trig[i] as i64) == LB_CH_COMMA { atend = 1 } }
94 if atend == 1 {
95 var a: i64 = s
96 var e: i64 = i
97 var trim: i64 = 1
98 while trim == 1 { if a < e { if (trig[a] as i64) == LB_CH_SPACE { a = a + 1 } else { trim = 0 } } else { trim = 0 } }
99 trim = 1
100 while trim == 1 { if e > a { if (trig[e - 1] as i64) == LB_CH_SPACE { e = e - 1 } else { trim = 0 } } else { trim = 0 } }
101 if e > a { if lb_ci_find(umsg, umlen, ((trig as i64) + a) as *u8, e - a) >= 0 { hit = 1 } }
102 s = i + 1
103 }
104 i = i + 1
105 }
106 return hit
107}
108
109// THE CONTRACT against a named conf. Writes the [WORLD] block (or the refusal line) into out, NUL-terminated; returns bytes.
110func em_lorebook_from(path: *u8, umsg: *u8, umlen: i64, out: *u8, cap: i64, stats: *i64) -> i64 {
111 var k: i64 = 0
112 while k < LB_STATS_N { stats[k] = 0; k = k + 1 }
113 stats[LB_ST_BUDGET] = LB_BUDGET_DEFAULT
114 if cap > 0 { out[0] = 0 as u8 }
115 if umlen <= 0 { return 0 }
116 if cap <= 1 { return 0 }
117 let lp: *i64 = sys_mmap(16) as *i64
118 lp[0] = 0
119 let b: *u8 = sys_read_file(path, lp)
120 if (b as i64) == 0 { return 0 }
121 let n: i64 = lp[0]
122 if n <= 0 { return 0 }
123 // fired entries accumulate here; bounded by the conf's own size plus the per-entry decoration, so it cannot overflow
124 let acc: *u8 = sys_mmap(n + LB_ACC_HEADROOM)
125 var ao: i64 = 0
126 var ls: i64 = 0
127 var i: i64 = 0
128 while i <= n {
129 var atend: i64 = 0
130 if i == n { atend = 1 } else { if (b[i] as i64) == LB_CH_NL { atend = 1 } }
131 if atend == 1 {
132 var le: i64 = i
133 if le > ls { if (b[le - 1] as i64) == LB_CH_CR { le = le - 1 } }
134 if le > ls {
135 let c0: i64 = b[ls] as i64
136 if c0 != LB_CH_HASH {
137 if c0 == LB_CH_AT {
138 if lb_ci_find(((b as i64) + ls) as *u8, le - ls, LB_BUDGET_KEY, lb_len(LB_BUDGET_KEY)) == 0 {
139 var v: i64 = 0
140 var seen: i64 = 0
141 var q: i64 = ls + lb_len(LB_BUDGET_KEY)
142 while q < le { let d: i64 = b[q] as i64; if d >= LB_CH_0 { if d <= LB_CH_9 { v = v * LB_DEC_BASE + (d - LB_CH_0); seen = 1 } } q = q + 1 }
143 if seen == 1 { if v > 0 { stats[LB_ST_BUDGET] = v } }
144 }
145 } else {
146 var p1: i64 = 0 - 1
147 var p2: i64 = 0 - 1
148 var q2: i64 = ls
149 while q2 < le { if (b[q2] as i64) == LB_CH_PIPE { if p1 < 0 { p1 = q2 } else { if p2 < 0 { p2 = q2 } } } q2 = q2 + 1 }
150 if p2 < 0 { stats[LB_ST_MALFORMED] = stats[LB_ST_MALFORMED] + 1 } else {
151 stats[LB_ST_ROWS] = stats[LB_ST_ROWS] + 1
152 if p2 + 1 < le {
153 if lb_triggers_hit(((b as i64) + p1 + 1) as *u8, p2 - p1 - 1, umsg, umlen) == 1 {
154 stats[LB_ST_FIRED] = stats[LB_ST_FIRED] + 1
155 ao = lb_cat(acc, ao, LB_ITEM)
156 ao = lb_catn(acc, ao, ((b as i64) + ls) as *u8, p1 - ls)
157 ao = lb_cat(acc, ao, LB_SEP)
158 ao = lb_catn(acc, ao, ((b as i64) + p2 + 1) as *u8, le - p2 - 1)
159 acc[ao] = LB_CH_NL as u8
160 ao = ao + 1
161 }
162 }
163 }
164 }
165 }
166 }
167 ls = i + 1
168 }
169 i = i + 1
170 }
171 if stats[LB_ST_FIRED] == 0 { return 0 }
172 // the effective budget is the smaller of the conf's and the caller's buffer -- one refusal shape, both bounds honest
173 if cap - 1 < stats[LB_ST_BUDGET] { stats[LB_ST_BUDGET] = cap - 1 }
174 let wanted: i64 = lb_len(LB_HEAD) + ao
175 stats[LB_ST_WANTED] = wanted
176 if wanted > stats[LB_ST_BUDGET] {
177 stats[LB_ST_REFUSED] = 1
178 var ro: i64 = 0
179 if cap > LB_REFUSE_MIN_CAP {
180 ro = lb_cat(out, 0, LB_REFUSE_HEAD)
181 ro = lb_itoa(out, ro, wanted)
182 ro = lb_cat(out, ro, LB_REFUSE_MID)
183 ro = lb_itoa(out, ro, stats[LB_ST_BUDGET])
184 ro = lb_cat(out, ro, LB_REFUSE_TAIL)
185 }
186 out[ro] = 0 as u8
187 return ro
188 }
189 var o: i64 = lb_cat(out, 0, LB_HEAD)
190 o = lb_catn(out, o, acc, ao)
191 out[o] = 0 as u8
192 return o
193}
194
195// THE CONTRACT: the daemon-cwd conf when it exists and is non-empty, else the nishihost tree's.
196func em_lorebook(umsg: *u8, umlen: i64, out: *u8, cap: i64, stats: *i64) -> i64 {
197 let lp: *i64 = sys_mmap(16) as *i64
198 lp[0] = 0
199 let b: *u8 = sys_read_file(LB_CONF_REL, lp)
200 if (b as i64) != 0 { if lp[0] > 0 { return em_lorebook_from(LB_CONF_REL, umsg, umlen, out, cap, stats) } }
201 return em_lorebook_from(LB_CONF_ABS, umsg, umlen, out, cap, stats)
202}