Skip to content

Commit 1831488

Browse files
author
StackMemory Bot (CLI)
committed
feat(tokens): replace char/4 heuristic with js-tiktoken (cl100k_base)
Centralizes token estimation across 14 files through src/core/cache/token-estimator.ts and packages/sdk/src/token-estimator.ts. Lazy-loads cl100k_base encoder with char/4 fallback if WASM fails. Also ports context-budget hook to codex-sm exit handler for compact/restart nudges matching Claude Code behavior.
1 parent 6395ce0 commit 1831488

24 files changed

Lines changed: 193 additions & 104 deletions

package-lock.json

Lines changed: 14 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
"helmet": "^8.1.0",
180180
"ignore": "^7.0.5",
181181
"inquirer": "^9.3.8",
182+
"js-tiktoken": "^1.0.21",
182183
"msgpackr": "^1.10.1",
183184
"node-pty": "^1.1.0",
184185
"open": "^11.0.0",

packages/sdk/package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
},
2323
"dependencies": {
2424
"better-sqlite3": "^11.8.1",
25+
"js-tiktoken": "^1.0.21",
2526
"js-yaml": "^4.1.0",
2627
"zod": "^3.24.2"
2728
},

packages/sdk/src/__tests__/sdk.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,13 @@ describe('StackMemory SDK', () => {
273273
});
274274

275275
describe('pure functions', () => {
276-
it('estimateTokens approximates', () => {
277-
expect(estimateTokens('hello')).toBe(2);
276+
it('estimateTokens returns positive count for non-empty strings', () => {
277+
expect(estimateTokens('hello')).toBeGreaterThan(0);
278278
expect(estimateTokens('')).toBe(0);
279+
// Longer text should produce more tokens
280+
expect(estimateTokens('hello world foo bar baz')).toBeGreaterThan(
281+
estimateTokens('hello')
282+
);
279283
});
280284

281285
it('hashContent is deterministic', () => {

packages/sdk/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ export { ProvenanceStore } from './provenance.js';
1515

1616
// Pure functions
1717
export { scoreConfidence } from './confidence-scorer.js';
18-
export { estimateTokens, hashContent } from './token-estimator.js';
18+
export {
19+
estimateTokens,
20+
isTiktokenActive,
21+
hashContent,
22+
} from './token-estimator.js';
1923

2024
// Types
2125
export type {

packages/sdk/src/token-estimator.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,46 @@
11
/**
22
* Token estimation and content hashing utilities.
3+
*
4+
* Uses js-tiktoken (cl100k_base) for accurate counts.
5+
* Falls back to chars/4 heuristic if encoder fails to load.
36
*/
47

58
import { createHash } from 'crypto';
9+
import { createRequire } from 'module';
610

7-
/** Estimate token count using chars/4 approximation. */
11+
type Encoder = { encode: (text: string) => number[] };
12+
13+
let encoder: Encoder | null = null;
14+
let initAttempted = false;
15+
16+
function getEncoder(): Encoder | null {
17+
if (initAttempted) return encoder;
18+
initAttempted = true;
19+
try {
20+
const require = createRequire(import.meta.url);
21+
const tiktoken = require('js-tiktoken');
22+
encoder = tiktoken.getEncoding('cl100k_base');
23+
} catch {
24+
encoder = null;
25+
}
26+
return encoder;
27+
}
28+
29+
/** Estimate token count. Accurate when tiktoken loads, heuristic otherwise. */
830
export function estimateTokens(content: string): number {
931
if (!content) return 0;
32+
const enc = getEncoder();
33+
if (enc) {
34+
return enc.encode(content).length;
35+
}
1036
return Math.ceil(content.length / 4);
1137
}
1238

39+
/** Whether tiktoken is active (for diagnostics). */
40+
export function isTiktokenActive(): boolean {
41+
return getEncoder() !== null;
42+
}
43+
1344
/** SHA-256 hex digest of content. */
1445
export function hashContent(content: string): string {
1546
return createHash('sha256').update(content).digest('hex');

src/cli/codex-sm.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,54 @@ class CodexSM {
253253
}
254254
}
255255

256+
/**
257+
* Emit context budget advice based on tool-call count from checkpoint state.
258+
* Mirrors the Claude Code context-budget.js hook.
259+
*/
260+
private emitContextBudgetAdvice(): void {
261+
const COMPACT_SUGGEST = 50;
262+
const COMPACT_STRONG = 65;
263+
const RESTART_RECOMMEND = 80;
264+
265+
try {
266+
const stateFile = path.join(
267+
os.homedir(),
268+
'.stackmemory',
269+
`checkpoint-state-${this.config.instanceId}.json`
270+
);
271+
if (!fs.existsSync(stateFile)) return;
272+
273+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
274+
const cwd = process.cwd();
275+
const toolCount = state.projects?.[cwd]?.toolCount || 0;
276+
277+
if (toolCount >= RESTART_RECOMMEND) {
278+
console.log(
279+
chalk.yellow(
280+
`[CONTEXT_BUDGET] ${toolCount} tool calls (~150K+ tokens). ` +
281+
`Recommend: save context then start fresh session.`
282+
)
283+
);
284+
} else if (toolCount >= COMPACT_STRONG) {
285+
console.log(
286+
chalk.yellow(
287+
`[CONTEXT_BUDGET] ${toolCount} tool calls (~100-130K tokens). ` +
288+
`Context heavy — consider compacting or restarting.`
289+
)
290+
);
291+
} else if (toolCount >= COMPACT_SUGGEST) {
292+
console.log(
293+
chalk.gray(
294+
`[CONTEXT_BUDGET] ${toolCount} tool calls (~80-100K tokens). ` +
295+
`Context getting heavy.`
296+
)
297+
);
298+
}
299+
} catch {
300+
// Silent — never block exit
301+
}
302+
}
303+
256304
private loadContext(): void {
257305
if (!this.config.contextEnabled) return;
258306
try {
@@ -591,6 +639,9 @@ class CodexSM {
591639
// Non-fatal: don't block exit
592640
}
593641

642+
// Context budget check
643+
this.emitContextBudgetAdvice();
644+
594645
if (this.config.tracingEnabled) {
595646
const summary = trace.getExecutionSummary();
596647
console.log();

src/cli/commands/orchestrator.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import { spawn, execSync, type ChildProcess } from 'child_process';
12+
import { estimateTokens } from '../../core/cache/token-estimator.js';
1213
import {
1314
appendFileSync,
1415
existsSync,
@@ -2295,7 +2296,7 @@ export class Conductor {
22952296
}
22962297
if (block.type === 'text' && block.text) {
22972298
const text = block.text as string;
2298-
run.tokensUsed += Math.ceil(text.length / 4);
2299+
run.tokensUsed += estimateTokens(text);
22992300
turnTextParts.push(text);
23002301
}
23012302
}
@@ -2489,7 +2490,7 @@ export class Conductor {
24892490

24902491
// Estimate tokens from message sizes
24912492
if (msg.method === 'item/text' && params?.text) {
2492-
run.tokensUsed += Math.ceil((params.text as string).length / 4);
2493+
run.tokensUsed += estimateTokens(params.text as string);
24932494
}
24942495

24952496
// Update agent status file periodically (every 5 tool calls)

src/core/cache/token-estimator.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,48 @@
11
/**
2-
* Token estimation and content hashing utilities
2+
* Token estimation and content hashing utilities.
3+
*
4+
* Uses js-tiktoken (cl100k_base) for accurate counts.
5+
* Falls back to chars/4 heuristic if encoder fails to load.
36
*/
47

58
import { createHash } from 'crypto';
9+
import { createRequire } from 'module';
10+
11+
type Encoder = { encode: (text: string) => number[] };
12+
13+
let encoder: Encoder | null = null;
14+
let initAttempted = false;
15+
16+
function getEncoder(): Encoder | null {
17+
if (initAttempted) return encoder;
18+
initAttempted = true;
19+
try {
20+
const require = createRequire(import.meta.url);
21+
const tiktoken = require('js-tiktoken');
22+
encoder = tiktoken.getEncoding('cl100k_base');
23+
} catch {
24+
encoder = null;
25+
}
26+
return encoder;
27+
}
628

729
/**
8-
* Estimate token count using chars/4 approximation.
9-
* Good enough for cache dedup -- no tiktoken dependency needed.
30+
* Estimate token count. Accurate when tiktoken loads, heuristic otherwise.
1031
*/
1132
export function estimateTokens(content: string): number {
1233
if (!content) return 0;
34+
const enc = getEncoder();
35+
if (enc) {
36+
return enc.encode(content).length;
37+
}
1338
return Math.ceil(content.length / 4);
1439
}
1540

41+
/** Whether tiktoken is active (for diagnostics). */
42+
export function isTiktokenActive(): boolean {
43+
return getEncoder() !== null;
44+
}
45+
1646
/**
1747
* SHA-256 hex digest of content for content-addressable lookup.
1848
*/

0 commit comments

Comments
 (0)