@jeremysnr/snug 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeremy Smith
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # snug
2
+
3
+ **Fit prioritised content into a token budget.**
4
+
5
+ Every LLM application has the same problem: you have a context window of N tokens and need to fit a system prompt, conversation history, retrieved documents, and tool definitions into it — with space left for the model's reply. Every team writes their own solution from scratch.
6
+
7
+ `snug` is a single function that solves this once.
8
+
9
+ ```ts
10
+ import { fit } from 'snug';
11
+
12
+ const { included } = fit(
13
+ [
14
+ { id: 'system', content: systemPrompt, priority: 100 },
15
+ { id: 'history', content: chatHistory, priority: 60 },
16
+ { id: 'rag', content: retrievedDocs, priority: 40 },
17
+ ],
18
+ { budget: 8192, reserve: 1024, tokenizer: myTokenizer },
19
+ );
20
+
21
+ // included — items that fit, in original input order
22
+ // excluded — items that didn't fit
23
+ ```
24
+
25
+ Items are selected greedily in descending priority order. The result preserves original input order. Zero dependencies. Works in Node, Deno, Bun, and edge runtimes.
26
+
27
+ ## Install
28
+
29
+ ```
30
+ npm install snug
31
+ ```
32
+
33
+ ## API
34
+
35
+ ### `fit(items, options)`
36
+
37
+ ```ts
38
+ fit(items: Item[], options: FitOptions): FitResult
39
+ ```
40
+
41
+ **Item**
42
+
43
+ | Field | Type | Description |
44
+ |-------|------|-------------|
45
+ | `id` | `string` | Unique identifier |
46
+ | `content` | `unknown` | Your content — not inspected by snug |
47
+ | `priority` | `number` | Higher = included first |
48
+ | `tokens` | `number` | Pre-counted cost (optional — see below) |
49
+ | `pairId` | `string` | Atomic pair group (optional — see below) |
50
+
51
+ **FitOptions**
52
+
53
+ | Field | Type | Default | Description |
54
+ |-------|------|---------|-------------|
55
+ | `budget` | `number` | — | Token limit for included items |
56
+ | `tokenizer` | `(text: string) => number` | built-in approx | Your token counter |
57
+ | `reserve` | `number` | `0` | Tokens to hold back (e.g. for model response) |
58
+ | `suppressApproximationWarning` | `boolean` | `false` | Silence the no-tokenizer warning |
59
+
60
+ **FitResult**
61
+
62
+ ```ts
63
+ {
64
+ included: Item[]; // items that fit, original order
65
+ excluded: Item[]; // items that didn't fit
66
+ tokensUsed: number;
67
+ tokensRemaining: number;
68
+ }
69
+ ```
70
+
71
+ ## Pair constraints
72
+
73
+ Anthropic's API requires strict 1:1 pairing between `tool_use` and `tool_result` messages — orphaning either half causes a 400 error. Mark paired items with a shared `pairId` and snug treats them as an atomic unit: both are included or neither is.
74
+
75
+ ```ts
76
+ fit(
77
+ [
78
+ { id: 'use', content: toolUse, priority: 80, pairId: 'call-1' },
79
+ { id: 'result', content: toolResult, priority: 80, pairId: 'call-1' },
80
+ ],
81
+ { budget: 2048, tokenizer },
82
+ );
83
+ ```
84
+
85
+ All items in a pair group must share the same `priority`.
86
+
87
+ ## Token counting
88
+
89
+ Pass any `(text: string) => number` function:
90
+
91
+ ```ts
92
+ // tiktoken (OpenAI / Anthropic)
93
+ import { encoding_for_model } from 'tiktoken';
94
+ const enc = encoding_for_model('gpt-4o');
95
+ const tokenizer = (text: string) => enc.encode(text).length;
96
+ ```
97
+
98
+ If you already have a token count (e.g. from an API usage response), pass it directly via the `tokens` field and skip counting entirely:
99
+
100
+ ```ts
101
+ { id: 'msg', content: msg, priority: 50, tokens: 342 }
102
+ ```
103
+
104
+ When no tokenizer is supplied, snug falls back to `Math.ceil(text.length / 4)` and prints a warning. This is useful for prototyping but can be off by up to 37% in production.
105
+
106
+ ## Licence
107
+
108
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ approximateTokens: () => approximateTokens,
24
+ fit: () => fit
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+ var APPROX_WARNING = "[snug] No tokenizer supplied. Using a character-based approximation (~4 chars/token). This can be off by up to 37% on large payloads. Pass a real tokenizer via options.tokenizer for production use.";
28
+ function approximateTokens(text) {
29
+ return Math.ceil(text.length / 4);
30
+ }
31
+ function resolveTokens(item, tokenizer) {
32
+ if (item.tokens !== void 0) return item.tokens;
33
+ if (typeof item.content === "string") return tokenizer(item.content);
34
+ throw new Error(
35
+ `[snug] Item "${item.id}" has no \`tokens\` field and its \`content\` is not a string.`
36
+ );
37
+ }
38
+ function validateItems(items) {
39
+ const pairPriority = /* @__PURE__ */ new Map();
40
+ for (const item of items) {
41
+ if (!Number.isFinite(item.priority)) {
42
+ throw new Error(`[snug] Item "${item.id}" has a non-finite priority: ${item.priority}`);
43
+ }
44
+ if (item.pairId !== void 0) {
45
+ const existing = pairPriority.get(item.pairId);
46
+ if (existing === void 0) {
47
+ pairPriority.set(item.pairId, item.priority);
48
+ } else if (existing !== item.priority) {
49
+ throw new Error(
50
+ `[snug] All items in pair group "${item.pairId}" must have the same priority. Found ${existing} and ${item.priority}.`
51
+ );
52
+ }
53
+ }
54
+ }
55
+ }
56
+ function fit(items, options) {
57
+ const { budget, reserve = 0, suppressApproximationWarning = false } = options;
58
+ if (!Number.isFinite(budget) || budget <= 0) {
59
+ throw new Error(`[snug] budget must be a positive finite number. Got: ${budget}`);
60
+ }
61
+ if (!Number.isFinite(reserve) || reserve < 0) {
62
+ throw new Error(`[snug] reserve must be a non-negative finite number. Got: ${reserve}`);
63
+ }
64
+ const effectiveBudget = budget - reserve;
65
+ if (effectiveBudget <= 0) {
66
+ throw new Error(`[snug] reserve (${reserve}) must be less than budget (${budget}).`);
67
+ }
68
+ const tokenizer = options.tokenizer ?? (() => {
69
+ if (!suppressApproximationWarning) console.warn(APPROX_WARNING);
70
+ return approximateTokens;
71
+ })();
72
+ validateItems(items);
73
+ const costs = new Map(
74
+ items.map((item) => [item.id, resolveTokens(item, tokenizer)])
75
+ );
76
+ const groupMap = /* @__PURE__ */ new Map();
77
+ for (let i = 0; i < items.length; i++) {
78
+ const item = items[i];
79
+ const key = item.pairId ?? item.id;
80
+ if (!groupMap.has(key)) {
81
+ groupMap.set(key, { items: [], totalTokens: 0, priority: item.priority, firstIndex: i });
82
+ }
83
+ const g = groupMap.get(key);
84
+ g.items.push(item);
85
+ g.totalTokens += costs.get(item.id);
86
+ }
87
+ const groups = [...groupMap.values()].sort(
88
+ (a, b) => b.priority - a.priority || a.firstIndex - b.firstIndex
89
+ );
90
+ const includedIds = /* @__PURE__ */ new Set();
91
+ let tokensUsed = 0;
92
+ for (const g of groups) {
93
+ if (tokensUsed + g.totalTokens <= effectiveBudget) {
94
+ for (const item of g.items) includedIds.add(item.id);
95
+ tokensUsed += g.totalTokens;
96
+ }
97
+ }
98
+ const included = [];
99
+ const excluded = [];
100
+ for (const item of items) {
101
+ (includedIds.has(item.id) ? included : excluded).push(item);
102
+ }
103
+ return { included, excluded, tokensUsed, tokensRemaining: effectiveBudget - tokensUsed };
104
+ }
105
+ // Annotate the CommonJS export names for ESM import in node:
106
+ 0 && (module.exports = {
107
+ approximateTokens,
108
+ fit
109
+ });
@@ -0,0 +1,25 @@
1
+ type Tokenizer = (text: string) => number;
2
+ interface Item {
3
+ id: string;
4
+ content: unknown;
5
+ tokens?: number;
6
+ priority: number;
7
+ /** Items sharing a pairId are included or excluded as a unit. */
8
+ pairId?: string;
9
+ }
10
+ interface FitOptions {
11
+ budget: number;
12
+ tokenizer?: Tokenizer;
13
+ reserve?: number;
14
+ suppressApproximationWarning?: boolean;
15
+ }
16
+ interface FitResult<T extends Item = Item> {
17
+ included: T[];
18
+ excluded: T[];
19
+ tokensUsed: number;
20
+ tokensRemaining: number;
21
+ }
22
+ declare function approximateTokens(text: string): number;
23
+ declare function fit<T extends Item>(items: T[], options: FitOptions): FitResult<T>;
24
+
25
+ export { type FitOptions, type FitResult, type Item, type Tokenizer, approximateTokens, fit };
@@ -0,0 +1,25 @@
1
+ type Tokenizer = (text: string) => number;
2
+ interface Item {
3
+ id: string;
4
+ content: unknown;
5
+ tokens?: number;
6
+ priority: number;
7
+ /** Items sharing a pairId are included or excluded as a unit. */
8
+ pairId?: string;
9
+ }
10
+ interface FitOptions {
11
+ budget: number;
12
+ tokenizer?: Tokenizer;
13
+ reserve?: number;
14
+ suppressApproximationWarning?: boolean;
15
+ }
16
+ interface FitResult<T extends Item = Item> {
17
+ included: T[];
18
+ excluded: T[];
19
+ tokensUsed: number;
20
+ tokensRemaining: number;
21
+ }
22
+ declare function approximateTokens(text: string): number;
23
+ declare function fit<T extends Item>(items: T[], options: FitOptions): FitResult<T>;
24
+
25
+ export { type FitOptions, type FitResult, type Item, type Tokenizer, approximateTokens, fit };
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ // src/index.ts
2
+ var APPROX_WARNING = "[snug] No tokenizer supplied. Using a character-based approximation (~4 chars/token). This can be off by up to 37% on large payloads. Pass a real tokenizer via options.tokenizer for production use.";
3
+ function approximateTokens(text) {
4
+ return Math.ceil(text.length / 4);
5
+ }
6
+ function resolveTokens(item, tokenizer) {
7
+ if (item.tokens !== void 0) return item.tokens;
8
+ if (typeof item.content === "string") return tokenizer(item.content);
9
+ throw new Error(
10
+ `[snug] Item "${item.id}" has no \`tokens\` field and its \`content\` is not a string.`
11
+ );
12
+ }
13
+ function validateItems(items) {
14
+ const pairPriority = /* @__PURE__ */ new Map();
15
+ for (const item of items) {
16
+ if (!Number.isFinite(item.priority)) {
17
+ throw new Error(`[snug] Item "${item.id}" has a non-finite priority: ${item.priority}`);
18
+ }
19
+ if (item.pairId !== void 0) {
20
+ const existing = pairPriority.get(item.pairId);
21
+ if (existing === void 0) {
22
+ pairPriority.set(item.pairId, item.priority);
23
+ } else if (existing !== item.priority) {
24
+ throw new Error(
25
+ `[snug] All items in pair group "${item.pairId}" must have the same priority. Found ${existing} and ${item.priority}.`
26
+ );
27
+ }
28
+ }
29
+ }
30
+ }
31
+ function fit(items, options) {
32
+ const { budget, reserve = 0, suppressApproximationWarning = false } = options;
33
+ if (!Number.isFinite(budget) || budget <= 0) {
34
+ throw new Error(`[snug] budget must be a positive finite number. Got: ${budget}`);
35
+ }
36
+ if (!Number.isFinite(reserve) || reserve < 0) {
37
+ throw new Error(`[snug] reserve must be a non-negative finite number. Got: ${reserve}`);
38
+ }
39
+ const effectiveBudget = budget - reserve;
40
+ if (effectiveBudget <= 0) {
41
+ throw new Error(`[snug] reserve (${reserve}) must be less than budget (${budget}).`);
42
+ }
43
+ const tokenizer = options.tokenizer ?? (() => {
44
+ if (!suppressApproximationWarning) console.warn(APPROX_WARNING);
45
+ return approximateTokens;
46
+ })();
47
+ validateItems(items);
48
+ const costs = new Map(
49
+ items.map((item) => [item.id, resolveTokens(item, tokenizer)])
50
+ );
51
+ const groupMap = /* @__PURE__ */ new Map();
52
+ for (let i = 0; i < items.length; i++) {
53
+ const item = items[i];
54
+ const key = item.pairId ?? item.id;
55
+ if (!groupMap.has(key)) {
56
+ groupMap.set(key, { items: [], totalTokens: 0, priority: item.priority, firstIndex: i });
57
+ }
58
+ const g = groupMap.get(key);
59
+ g.items.push(item);
60
+ g.totalTokens += costs.get(item.id);
61
+ }
62
+ const groups = [...groupMap.values()].sort(
63
+ (a, b) => b.priority - a.priority || a.firstIndex - b.firstIndex
64
+ );
65
+ const includedIds = /* @__PURE__ */ new Set();
66
+ let tokensUsed = 0;
67
+ for (const g of groups) {
68
+ if (tokensUsed + g.totalTokens <= effectiveBudget) {
69
+ for (const item of g.items) includedIds.add(item.id);
70
+ tokensUsed += g.totalTokens;
71
+ }
72
+ }
73
+ const included = [];
74
+ const excluded = [];
75
+ for (const item of items) {
76
+ (includedIds.has(item.id) ? included : excluded).push(item);
77
+ }
78
+ return { included, excluded, tokensUsed, tokensRemaining: effectiveBudget - tokensUsed };
79
+ }
80
+ export {
81
+ approximateTokens,
82
+ fit
83
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@jeremysnr/snug",
3
+ "version": "0.1.0",
4
+ "description": "Fit prioritised content into a token budget. Zero dependencies.",
5
+ "keywords": [
6
+ "llm",
7
+ "tokens",
8
+ "context-window",
9
+ "budget",
10
+ "priority"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Jeremy Smith",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/JeremySNR/snug.git"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.cjs",
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsup",
34
+ "test": "node node_modules/jest/bin/jest.js",
35
+ "prepublishOnly": "npm run build",
36
+ "demo": "npx tsx examples/demo.ts"
37
+ },
38
+ "devDependencies": {
39
+ "@types/jest": "^29.5.12",
40
+ "@types/node": "^20.14.0",
41
+ "jest": "^29.7.0",
42
+ "ts-jest": "^29.1.5",
43
+ "tsup": "^8.5.1",
44
+ "typescript": "^5.5.0"
45
+ }
46
+ }