@knolo/cli 3.2.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +49 -3
  2. package/bin/knolo.mjs +476 -7
  3. package/package.json +4 -3
  4. package/templates/icp-knowledge-canister/README.md +71 -0
  5. package/templates/icp-knowledge-canister/canisters/knolo-core-rust/Cargo.toml +13 -0
  6. package/templates/icp-knowledge-canister/canisters/knolo-core-rust/src/lib.rs +499 -0
  7. package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/Cargo.lock +541 -0
  8. package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/Cargo.toml +20 -0
  9. package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/knolo_icp.did +29 -0
  10. package/templates/icp-knowledge-canister/canisters/knolo-icp-canister/src/lib.rs +605 -0
  11. package/templates/icp-knowledge-canister/dfx.json +25 -0
  12. package/templates/icp-knowledge-canister/frontend/index.html +12 -0
  13. package/templates/icp-knowledge-canister/frontend/package.json +27 -0
  14. package/templates/icp-knowledge-canister/frontend/src/App.tsx +225 -0
  15. package/templates/icp-knowledge-canister/frontend/src/app.css +271 -0
  16. package/templates/icp-knowledge-canister/frontend/src/canister.ts +119 -0
  17. package/templates/icp-knowledge-canister/frontend/src/canisterConfig.test.ts +34 -0
  18. package/templates/icp-knowledge-canister/frontend/src/canisterConfig.ts +26 -0
  19. package/templates/icp-knowledge-canister/frontend/src/main.tsx +5 -0
  20. package/templates/icp-knowledge-canister/frontend/src/vite-env.d.ts +14 -0
  21. package/templates/icp-knowledge-canister/frontend/tsconfig.json +18 -0
  22. package/templates/icp-knowledge-canister/frontend/vite.config.ts +66 -0
  23. package/templates/icp-knowledge-canister/knolo.config.json +15 -0
  24. package/templates/icp-knowledge-canister/knowledge/alpha.md +5 -0
  25. package/templates/icp-knowledge-canister/knowledge/guides/beta.md +5 -0
  26. package/templates/icp-knowledge-canister/scripts/build-sample-pack.mjs +24 -0
  27. package/templates/icp-knowledge-canister/scripts/query.sh +11 -0
  28. package/templates/icp-knowledge-canister/scripts/upload-pack.sh +16 -0
@@ -0,0 +1,71 @@
1
+ # icp-knowledge-canister
2
+
3
+ Minimal local `dfx` example for deploying a Knolo ICP canister without any middleware server.
4
+
5
+ This scaffold:
6
+
7
+ - builds a real `.knolo` pack from local sample docs
8
+ - vendors the Rust canister sources locally under `canisters/`
9
+ - uploads the pack with `knolo icp upload`
10
+ - queries the canister directly
11
+ - includes a tiny Vite + React browser client that talks to the canister directly
12
+
13
+ ## Prerequisites
14
+
15
+ Local ICP development needs `dfx`, Rust, the WASM target, and the Knolo CLI:
16
+
17
+ ```bash
18
+ npm install -g @knolo/cli
19
+ rustup target add wasm32-unknown-unknown
20
+ ```
21
+
22
+ ## Run The Example
23
+
24
+ ```bash
25
+ cd icp-knowledge-canister
26
+ dfx start --background
27
+ dfx deploy
28
+ knolo icp build-pack ./knowledge --out ./dist/knowledge.knolo
29
+ knolo icp upload ./dist/knowledge.knolo --canister knolo_knowledge
30
+ knolo icp query "alpha beta" --canister knolo_knowledge
31
+ ```
32
+
33
+ If `dfx` is running under a minimal shell and complains about terminal colors, rerun the `dfx` commands with:
34
+
35
+ ```bash
36
+ TERM=xterm-256color dfx start --background
37
+ TERM=xterm-256color dfx deploy
38
+ ```
39
+
40
+ To run the browser client:
41
+
42
+ ```bash
43
+ cd frontend
44
+ npm install
45
+ npm run dev
46
+ ```
47
+
48
+ The frontend uses `VITE_KNOLO_CANISTER_ID` when provided. Otherwise it falls back to the local canister ID from `.dfx/local/canister_ids.json`, which is available after `dfx deploy`.
49
+
50
+ ## What Gets Built
51
+
52
+ - `dfx deploy` compiles the local canister from `./canisters/knolo-icp-canister`
53
+ - `knolo icp build-pack ./knowledge --out ./dist/knowledge.knolo` writes `dist/knowledge.knolo`
54
+ - `knolo icp upload ./dist/knowledge.knolo --canister knolo_knowledge` calls `set_pack(bytes, label)`
55
+ - `knolo icp query "alpha beta" --canister knolo_knowledge` calls `search("alpha beta", 5)`
56
+
57
+ Helper wrappers remain available under `scripts/` if you prefer shorter commands:
58
+
59
+ ```bash
60
+ node scripts/build-sample-pack.mjs
61
+ bash scripts/upload-pack.sh
62
+ bash scripts/query.sh "alpha beta"
63
+ ```
64
+
65
+ ## Sample Content
66
+
67
+ The pack is built from the checked-in files under `knowledge/`, so the example stays deterministic and easy to rerun locally.
68
+ # Knolo v4 compatibility
69
+
70
+ This template uses the ICP legacy v3 pack profile. The TypeScript CLI’s
71
+ `icp build-pack` command opts into v3 until the canister supports v4.
@@ -0,0 +1,13 @@
1
+ [package]
2
+ name = "knolo-core-rust"
3
+ version = "4.0.0"
4
+ edition = "2021"
5
+ license = "Apache-2.0"
6
+ description = "Rust runtime support for reading and querying .knolo packs"
7
+ repository = "https://github.com/HiveForensics-AI/knolo-core"
8
+ keywords = ["knolo", "knowledge-base", "retrieval", "rag"]
9
+ categories = ["data-structures", "text-processing"]
10
+
11
+ [lib]
12
+ name = "knolo_core_rust"
13
+ path = "src/lib.rs"
@@ -0,0 +1,499 @@
1
+ use std::collections::{HashMap, HashSet};
2
+ use std::error::Error;
3
+ use std::fmt::{Display, Formatter};
4
+
5
+ #[derive(Debug, Clone)]
6
+ pub enum KnoloError {
7
+ InvalidPack(String),
8
+ }
9
+
10
+ impl Display for KnoloError {
11
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
12
+ match self {
13
+ KnoloError::InvalidPack(msg) => write!(f, "invalid pack: {msg}"),
14
+ }
15
+ }
16
+ }
17
+
18
+ impl Error for KnoloError {}
19
+
20
+ #[derive(Debug, Clone)]
21
+ pub struct PackMeta {
22
+ pub version: u32,
23
+ pub stats: PackStats,
24
+ }
25
+
26
+ #[derive(Debug, Clone)]
27
+ pub struct PackStats {
28
+ pub docs: usize,
29
+ pub blocks: usize,
30
+ pub terms: usize,
31
+ pub avg_block_len: Option<f64>,
32
+ }
33
+
34
+ #[derive(Debug, Clone)]
35
+ pub struct Pack {
36
+ pub meta: PackMeta,
37
+ pub lexicon: HashMap<String, u32>,
38
+ pub postings: Vec<u32>,
39
+ pub blocks: Vec<String>,
40
+ pub headings: Vec<Option<String>>,
41
+ pub doc_ids: Vec<Option<String>>,
42
+ pub namespaces: Vec<Option<String>>,
43
+ pub block_token_lens: Vec<usize>,
44
+ }
45
+
46
+ #[derive(Debug, Clone)]
47
+ pub struct QueryOptions {
48
+ pub top_k: usize,
49
+ pub min_score: f64,
50
+ pub namespace: Option<Vec<String>>,
51
+ pub source: Option<Vec<String>>,
52
+ }
53
+
54
+ impl Default for QueryOptions {
55
+ fn default() -> Self {
56
+ Self {
57
+ top_k: 10,
58
+ min_score: 0.0,
59
+ namespace: None,
60
+ source: None,
61
+ }
62
+ }
63
+ }
64
+
65
+ #[derive(Debug, Clone, PartialEq)]
66
+ pub struct Hit {
67
+ pub block_id: usize,
68
+ pub score: f64,
69
+ pub text: String,
70
+ pub source: Option<String>,
71
+ pub namespace: Option<String>,
72
+ }
73
+
74
+ pub fn mount_pack_from_bytes(bytes: &[u8]) -> Result<Pack, KnoloError> {
75
+ let mut cursor = 0usize;
76
+
77
+ let meta_len = read_u32(bytes, &mut cursor)? as usize;
78
+ let meta_json = read_slice(bytes, &mut cursor, meta_len)?;
79
+ let meta = parse_meta(std::str::from_utf8(meta_json).map_err(|_| KnoloError::InvalidPack("meta utf8".into()))?)?;
80
+
81
+ let lex_len = read_u32(bytes, &mut cursor)? as usize;
82
+ let lex_json = read_slice(bytes, &mut cursor, lex_len)?;
83
+ let lexicon = parse_lexicon(std::str::from_utf8(lex_json).map_err(|_| KnoloError::InvalidPack("lexicon utf8".into()))?)?;
84
+
85
+ let post_count = read_u32(bytes, &mut cursor)? as usize;
86
+ let postings = read_u32_array(bytes, &mut cursor, post_count)?;
87
+
88
+ let blocks_len = read_u32(bytes, &mut cursor)? as usize;
89
+ let blocks_json = read_slice(bytes, &mut cursor, blocks_len)?;
90
+ let blocks_str = std::str::from_utf8(blocks_json).map_err(|_| KnoloError::InvalidPack("blocks utf8".into()))?;
91
+ let parsed_blocks = parse_blocks(blocks_str)?;
92
+
93
+ Ok(Pack {
94
+ meta,
95
+ lexicon,
96
+ postings,
97
+ blocks: parsed_blocks.texts,
98
+ headings: parsed_blocks.headings,
99
+ doc_ids: parsed_blocks.doc_ids,
100
+ namespaces: parsed_blocks.namespaces,
101
+ block_token_lens: parsed_blocks.lens,
102
+ })
103
+ }
104
+
105
+ pub fn query(pack: &Pack, q: &str, opts: QueryOptions) -> Vec<Hit> {
106
+ if q.trim().is_empty() {
107
+ return vec![];
108
+ }
109
+ let tokens = tokenize(q);
110
+ if tokens.is_empty() {
111
+ return vec![];
112
+ }
113
+
114
+ let term_ids = tokens
115
+ .iter()
116
+ .filter_map(|t| pack.lexicon.get(t).copied())
117
+ .collect::<HashSet<_>>();
118
+ if term_ids.is_empty() {
119
+ return vec![];
120
+ }
121
+
122
+ let namespace_filter = normalize_filter(opts.namespace.as_ref());
123
+ let source_filter = normalize_filter(opts.source.as_ref());
124
+
125
+ let mut candidates: HashMap<usize, HashMap<u32, f64>> = HashMap::new();
126
+ let mut dfs: HashMap<u32, usize> = HashMap::new();
127
+ let uses_offset_block_ids = pack.meta.version >= 3;
128
+
129
+ let mut i = 0usize;
130
+ while i < pack.postings.len() {
131
+ let tid = pack.postings[i];
132
+ i += 1;
133
+ if tid == 0 {
134
+ continue;
135
+ }
136
+ let relevant = term_ids.contains(&tid);
137
+ let mut term_df = 0usize;
138
+
139
+ if i >= pack.postings.len() { break; }
140
+ let mut encoded_bid = pack.postings[i];
141
+ i += 1;
142
+
143
+ while encoded_bid != 0 && i < pack.postings.len() {
144
+ let bid = if uses_offset_block_ids {
145
+ encoded_bid.saturating_sub(1) as usize
146
+ } else {
147
+ encoded_bid as usize
148
+ };
149
+
150
+ let mut tf = 0usize;
151
+ while i < pack.postings.len() {
152
+ let pos = pack.postings[i];
153
+ i += 1;
154
+ if pos == 0 {
155
+ break;
156
+ }
157
+ tf += 1;
158
+ }
159
+
160
+ term_df += 1;
161
+ if relevant && bid < pack.blocks.len() {
162
+ let entry = candidates.entry(bid).or_default();
163
+ *entry.entry(tid).or_insert(0.0) += tf as f64;
164
+ }
165
+
166
+ if i >= pack.postings.len() { break; }
167
+ encoded_bid = pack.postings[i];
168
+ i += 1;
169
+ }
170
+
171
+ if relevant {
172
+ dfs.insert(tid, term_df);
173
+ }
174
+ }
175
+
176
+ if !namespace_filter.is_empty() {
177
+ candidates.retain(|bid, _| {
178
+ pack.namespaces
179
+ .get(*bid)
180
+ .and_then(|n| n.clone())
181
+ .map(|n| namespace_filter.contains(&normalize(&n)))
182
+ .unwrap_or(false)
183
+ });
184
+ }
185
+
186
+ if !source_filter.is_empty() {
187
+ candidates.retain(|bid, _| {
188
+ pack.doc_ids
189
+ .get(*bid)
190
+ .and_then(|n| n.clone())
191
+ .map(|n| source_filter.contains(&normalize(&n)))
192
+ .unwrap_or(false)
193
+ });
194
+ }
195
+
196
+ let doc_count = pack.meta.stats.blocks.max(1) as f64;
197
+ let avg_len = pack
198
+ .meta
199
+ .stats
200
+ .avg_block_len
201
+ .unwrap_or_else(|| {
202
+ if pack.block_token_lens.is_empty() {
203
+ 1.0
204
+ } else {
205
+ pack.block_token_lens.iter().sum::<usize>() as f64 / pack.block_token_lens.len() as f64
206
+ }
207
+ })
208
+ .max(1.0);
209
+
210
+ let mut scored = candidates
211
+ .into_iter()
212
+ .map(|(bid, tf_map)| {
213
+ let mut score = 0.0;
214
+ let len = *pack.block_token_lens.get(bid).unwrap_or(&1) as f64;
215
+ for (tid, tf) in tf_map {
216
+ let df = *dfs.get(&tid).unwrap_or(&0) as f64;
217
+ let idf = (1.0 + (doc_count - df + 0.5) / (df + 0.5)).ln();
218
+ let k1 = 1.5;
219
+ let b = 0.75;
220
+ let numer = tf * (k1 + 1.0);
221
+ let denom = tf + k1 * (1.0 - b + b * (len / avg_len));
222
+ score += idf * (numer / denom);
223
+ }
224
+ (bid, score)
225
+ })
226
+ .filter(|(_, score)| *score >= opts.min_score)
227
+ .collect::<Vec<_>>();
228
+
229
+ scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
230
+
231
+ scored
232
+ .into_iter()
233
+ .take(opts.top_k.max(1))
234
+ .map(|(bid, score)| Hit {
235
+ block_id: bid,
236
+ score,
237
+ text: pack.blocks.get(bid).cloned().unwrap_or_default(),
238
+ source: pack.doc_ids.get(bid).and_then(|s| s.clone()),
239
+ namespace: pack.namespaces.get(bid).and_then(|s| s.clone()),
240
+ })
241
+ .collect()
242
+ }
243
+
244
+ struct ParsedBlocks {
245
+ texts: Vec<String>,
246
+ headings: Vec<Option<String>>,
247
+ doc_ids: Vec<Option<String>>,
248
+ namespaces: Vec<Option<String>>,
249
+ lens: Vec<usize>,
250
+ }
251
+
252
+ fn parse_meta(json: &str) -> Result<PackMeta, KnoloError> {
253
+ Ok(PackMeta {
254
+ version: parse_u32_field(json, "version")?,
255
+ stats: PackStats {
256
+ docs: parse_u32_field(json, "docs")? as usize,
257
+ blocks: parse_u32_field(json, "blocks")? as usize,
258
+ terms: parse_u32_field(json, "terms")? as usize,
259
+ avg_block_len: parse_f64_field(json, "avgBlockLen"),
260
+ },
261
+ })
262
+ }
263
+
264
+ fn parse_lexicon(json: &str) -> Result<HashMap<String, u32>, KnoloError> {
265
+ let mut map = HashMap::new();
266
+ let s = compact(json);
267
+ let mut i = 0usize;
268
+ while let Some(start) = s[i..].find("[\"") {
269
+ let abs = i + start + 2;
270
+ let rest = &s[abs..];
271
+ let end = rest.find('"').ok_or_else(|| KnoloError::InvalidPack("lexicon key".into()))?;
272
+ let key = rest[..end].to_string();
273
+ let rest2 = &rest[end + 1..];
274
+ let comma = rest2.find(',').ok_or_else(|| KnoloError::InvalidPack("lexicon comma".into()))?;
275
+ let rest3 = &rest2[comma + 1..];
276
+ let mut n = String::new();
277
+ for ch in rest3.chars() {
278
+ if ch.is_ascii_digit() {
279
+ n.push(ch);
280
+ } else {
281
+ break;
282
+ }
283
+ }
284
+ if !n.is_empty() {
285
+ map.insert(key, n.parse::<u32>().map_err(|_| KnoloError::InvalidPack("lexicon tid".into()))?);
286
+ }
287
+ i = abs + end + 1;
288
+ }
289
+ Ok(map)
290
+ }
291
+
292
+ fn parse_blocks(json: &str) -> Result<ParsedBlocks, KnoloError> {
293
+ let s = compact(json);
294
+ if s.starts_with("[\"") {
295
+ let mut texts = Vec::new();
296
+ let mut i = 2usize;
297
+ while i < s.len() {
298
+ if let Some(end) = s[i..].find('"') {
299
+ let piece = &s[i..i + end];
300
+ texts.push(unescape(piece));
301
+ i += end + 1;
302
+ if let Some(next) = s[i..].find('"') {
303
+ i += next + 1;
304
+ } else {
305
+ break;
306
+ }
307
+ } else {
308
+ break;
309
+ }
310
+ }
311
+ let lens = texts.iter().map(|t| tokenize(t).len()).collect::<Vec<_>>();
312
+ return Ok(ParsedBlocks {
313
+ headings: vec![None; texts.len()],
314
+ doc_ids: vec![None; texts.len()],
315
+ namespaces: vec![None; texts.len()],
316
+ lens,
317
+ texts,
318
+ });
319
+ }
320
+
321
+ let objects = split_top_level_objects(&s)?;
322
+ let mut texts = Vec::new();
323
+ let mut headings = Vec::new();
324
+ let mut doc_ids = Vec::new();
325
+ let mut namespaces = Vec::new();
326
+ let mut lens = Vec::new();
327
+
328
+ for obj in objects {
329
+ let text = parse_string_or_null(&obj, "text").unwrap_or_default();
330
+ let len = parse_u32_field_optional(&obj, "len").map(|v| v as usize).unwrap_or_else(|| tokenize(&text).len());
331
+ texts.push(text);
332
+ headings.push(parse_string_or_null(&obj, "heading"));
333
+ doc_ids.push(parse_string_or_null(&obj, "docId"));
334
+ namespaces.push(parse_string_or_null(&obj, "namespace"));
335
+ lens.push(len);
336
+ }
337
+
338
+ Ok(ParsedBlocks { texts, headings, doc_ids, namespaces, lens })
339
+ }
340
+
341
+ fn split_top_level_objects(s: &str) -> Result<Vec<String>, KnoloError> {
342
+ let mut out = Vec::new();
343
+ let mut depth = 0i32;
344
+ let mut start = None;
345
+ let chars: Vec<char> = s.chars().collect();
346
+ for (i, ch) in chars.iter().enumerate() {
347
+ if *ch == '{' {
348
+ if depth == 0 {
349
+ start = Some(i);
350
+ }
351
+ depth += 1;
352
+ } else if *ch == '}' {
353
+ depth -= 1;
354
+ if depth == 0 {
355
+ if let Some(st) = start {
356
+ out.push(chars[st..=i].iter().collect());
357
+ }
358
+ start = None;
359
+ }
360
+ }
361
+ }
362
+ if out.is_empty() {
363
+ return Err(KnoloError::InvalidPack("blocks objects".into()));
364
+ }
365
+ Ok(out)
366
+ }
367
+
368
+ fn parse_string_or_null(obj: &str, key: &str) -> Option<String> {
369
+ let needle = format!("\"{}\":", key);
370
+ let idx = obj.find(&needle)? + needle.len();
371
+ let tail = &obj[idx..];
372
+ if tail.starts_with("null") {
373
+ return None;
374
+ }
375
+ if !tail.starts_with('"') {
376
+ return None;
377
+ }
378
+ let rest = &tail[1..];
379
+ let end = rest.find('"')?;
380
+ Some(unescape(&rest[..end]))
381
+ }
382
+
383
+ fn parse_u32_field(json: &str, key: &str) -> Result<u32, KnoloError> {
384
+ parse_u32_field_optional(json, key).ok_or_else(|| KnoloError::InvalidPack(format!("missing {key}")))
385
+ }
386
+
387
+ fn parse_u32_field_optional(json: &str, key: &str) -> Option<u32> {
388
+ let needle = format!("\"{}\":", key);
389
+ let idx = json.find(&needle)? + needle.len();
390
+ let tail = &json[idx..];
391
+ let mut n = String::new();
392
+ for ch in tail.chars() {
393
+ if ch.is_ascii_digit() {
394
+ n.push(ch);
395
+ } else if !n.is_empty() {
396
+ break;
397
+ }
398
+ }
399
+ n.parse().ok()
400
+ }
401
+
402
+ fn parse_f64_field(json: &str, key: &str) -> Option<f64> {
403
+ let needle = format!("\"{}\":", key);
404
+ let idx = json.find(&needle)? + needle.len();
405
+ let tail = &json[idx..];
406
+ let mut n = String::new();
407
+ for ch in tail.chars() {
408
+ if ch.is_ascii_digit() || ch == '.' {
409
+ n.push(ch);
410
+ } else if !n.is_empty() {
411
+ break;
412
+ }
413
+ }
414
+ n.parse().ok()
415
+ }
416
+
417
+ fn normalize_filter(values: Option<&Vec<String>>) -> HashSet<String> {
418
+ values
419
+ .map(|arr| arr.iter().map(|s| normalize(s)).collect::<HashSet<_>>())
420
+ .unwrap_or_default()
421
+ }
422
+
423
+ fn normalize(s: &str) -> String {
424
+ s.to_lowercase().trim().to_string()
425
+ }
426
+
427
+ fn tokenize(text: &str) -> Vec<String> {
428
+ let mut out = Vec::new();
429
+ let mut cur = String::new();
430
+ for ch in text.chars() {
431
+ if ch.is_alphanumeric() {
432
+ cur.push(ch.to_ascii_lowercase());
433
+ } else if !cur.is_empty() {
434
+ out.push(std::mem::take(&mut cur));
435
+ }
436
+ }
437
+ if !cur.is_empty() {
438
+ out.push(cur);
439
+ }
440
+ out
441
+ }
442
+
443
+ fn compact(s: &str) -> String {
444
+ let mut out = String::with_capacity(s.len());
445
+ let mut in_string = false;
446
+ let mut escaped = false;
447
+
448
+ for ch in s.chars() {
449
+ if in_string {
450
+ out.push(ch);
451
+ if escaped {
452
+ escaped = false;
453
+ } else if ch == '\\' {
454
+ escaped = true;
455
+ } else if ch == '"' {
456
+ in_string = false;
457
+ }
458
+ continue;
459
+ }
460
+
461
+ if ch.is_whitespace() {
462
+ continue;
463
+ }
464
+
465
+ out.push(ch);
466
+ if ch == '"' {
467
+ in_string = true;
468
+ }
469
+ }
470
+
471
+ out
472
+ }
473
+
474
+ fn unescape(s: &str) -> String {
475
+ s.replace("\\\"", "\"")
476
+ }
477
+
478
+ fn read_u32(bytes: &[u8], cursor: &mut usize) -> Result<u32, KnoloError> {
479
+ let chunk = read_slice(bytes, cursor, 4)?;
480
+ Ok(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
481
+ }
482
+
483
+ fn read_u32_array(bytes: &[u8], cursor: &mut usize, len: usize) -> Result<Vec<u32>, KnoloError> {
484
+ let mut out = Vec::with_capacity(len);
485
+ for _ in 0..len {
486
+ out.push(read_u32(bytes, cursor)?);
487
+ }
488
+ Ok(out)
489
+ }
490
+
491
+ fn read_slice<'a>(bytes: &'a [u8], cursor: &mut usize, len: usize) -> Result<&'a [u8], KnoloError> {
492
+ let end = cursor.saturating_add(len);
493
+ if end > bytes.len() {
494
+ return Err(KnoloError::InvalidPack("unexpected end-of-buffer".into()));
495
+ }
496
+ let slice = &bytes[*cursor..end];
497
+ *cursor = end;
498
+ Ok(slice)
499
+ }