Ship Assistent 0.14.0: chat sessions, ask-only hops, and context compression.

Per-chat Generate session with sparse deltas; drop Cards/Civitai/wanted hops; rolling history summary via the same Ollama model with a budget chip and /compress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 06:19:04 +03:00
co-authored by Cursor
parent d29436c944
commit 9ac24a828b
35 changed files with 5640 additions and 5652 deletions
+99
View File
@@ -0,0 +1,99 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
emptyContextMemory,
normalizeContextMemory,
charsToTokens,
estimateBudget,
shouldCompress,
assembleModelMessages,
messagesToFold,
mergeSummary,
conversationMemoryBlock,
} from '../src/context.js';
describe('context.js', () => {
it('charsToTokens ceil divide', () => {
assert.equal(charsToTokens(32, 3.2), 10);
assert.equal(charsToTokens(0, 3.2), 0);
});
it('estimateBudget prefers prompt_eval_count', () => {
const est = estimateBudget({
systemChars: 3200,
historyChars: 3200,
memoryChars: 0,
numCtx: 16384,
numPredict: 3072,
charsPerToken: 3.2,
compressAt: 0.7,
});
assert.equal(est.fromEval, false);
assert.ok(est.estimated > 0);
assert.equal(est.used, est.estimated);
const fact = estimateBudget({
...est,
systemChars: 3200,
historyChars: 3200,
promptEvalCount: 14000,
numCtx: 16384,
numPredict: 3072,
compressAt: 0.7,
});
assert.equal(fact.fromEval, true);
assert.equal(fact.used, 14000);
assert.equal(fact.level, 'hot');
});
it('shouldCompress only when over threshold and uncovered > keep', () => {
const budget = estimateBudget({
systemChars: 20000,
historyChars: 20000,
numCtx: 16384,
numPredict: 3072,
compressAt: 0.7,
charsPerToken: 3.2,
});
assert.equal(shouldCompress(budget, emptyContextMemory(), 20, { keepMessages: 8 }), true);
assert.equal(shouldCompress(budget, emptyContextMemory(), 6, { keepMessages: 8 }), false);
assert.equal(
shouldCompress(budget, { untilCount: 12, summary: 'x' }, 20, { keepMessages: 8 }),
false,
);
});
it('assembleModelMessages skips covered prefix then keeps last window', () => {
const history = [];
for (let i = 0; i < 12; i++) {
history.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `m${i}` });
}
const msgs = assembleModelMessages(history, { untilCount: 4 }, 2);
assert.equal(msgs.length, 4);
assert.equal(msgs[0].content, 'm8');
assert.equal(msgs[3].content, 'm11');
});
it('messagesToFold leaves keepTurns raw', () => {
const history = Array.from({ length: 10 }, (_, i) => ({
role: i % 2 === 0 ? 'user' : 'assistant',
content: `m${i}`,
}));
const fold = messagesToFold(history, emptyContextMemory(), 2);
assert.equal(fold.length, 6);
assert.equal(fold[0].content, 'm0');
assert.equal(fold[5].content, 'm5');
});
it('normalize + merge + conversation block', () => {
const m = normalizeContextMemory({ summary: ' hello ', untilCount: 4, uiCollapsed: true });
assert.equal(m.summary, 'hello');
assert.equal(m.uiCollapsed, true);
assert.equal(mergeSummary('old', 'new'), 'new');
assert.equal(mergeSummary('old', ''), 'old');
const block = conversationMemoryBlock(m, 100);
assert.equal(block.summary, 'hello');
assert.equal(block.until_count, 4);
assert.equal(conversationMemoryBlock(emptyContextMemory()), null);
});
});
+17 -9
View File
@@ -2,7 +2,6 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
userAsksLook,
userCommandsGenerate,
userAsksNoGenerate,
resolveTurnIntent,
packWantsVision,
@@ -14,17 +13,26 @@ describe('intent.js', () => {
assert.equal(userAsksLook('нарисуй лису'), false);
});
it('userCommandsGenerate respects veto', () => {
assert.equal(userCommandsGenerate('сгенерируй кадр'), true);
assert.equal(userCommandsGenerate('только запомни промпт'), false);
it('userAsksNoGenerate vetoes remember / no-gen', () => {
assert.equal(userAsksNoGenerate('только запомни промпт'), true);
assert.equal(userAsksNoGenerate('не генерируй'), true);
assert.equal(userAsksNoGenerate('нарисуй лису'), false);
});
it('resolveTurnIntent honors look without generate', () => {
const patch = { look_at: ['generate'] };
const intent = resolveTurnIntent(patch, 'что не так с кадром?', {}, 'ordinary');
assert.equal(intent.generate, false);
assert.equal(intent.look, true);
it('resolveTurnIntent uses model generate only + veto', () => {
const lookOnly = resolveTurnIntent({ look_at: ['generate'] }, 'что не так с кадром?', {});
assert.equal(lookOnly.generate, false);
assert.equal(lookOnly.look, true);
const gen = resolveTurnIntent({ prompt: 'fox', generate: true }, 'нарисуй лису', {});
assert.equal(gen.generate, true);
const vetoed = resolveTurnIntent({ prompt: 'fox', generate: true }, 'только запомни промпт', {});
assert.equal(vetoed.generate, false);
assert.equal(vetoed.vetoed, true);
const noHeuristic = resolveTurnIntent({ prompt: 'fox' }, 'нарисуй красивую лису в снегу', {});
assert.equal(noHeuristic.generate, false);
});
it('packWantsVision for critique pack', () => {
+54 -14
View File
@@ -3,35 +3,75 @@ import assert from 'node:assert/strict';
import {
extractPatch,
isPatchObject,
isCardObject,
normalizePatch,
setPatchKeys,
} from '../src/patch.js';
import {
mergeDelta,
emptySession,
patchWantsGenerate,
sessionFromLegacyParams,
toPersistParams,
resolveTurnIntent,
} from '../src/session.js';
describe('patch.js', () => {
it('extractPatch finds generation patch in fence', () => {
const text = 'Here you go\n```json\n{"prompt":"A red fox in snow","actions":["generate"]}\n```';
const text = 'Here you go\n```json\n{"prompt":"A red fox in snow","generate":true}\n```';
const { prose, patch } = extractPatch(text);
assert.ok(patch);
assert.equal(patch.prompt, 'A red fox in snow');
assert.equal(patch.generate, true);
assert.ok(!prose.includes('```'));
});
it('isCardObject vs isPatchObject', () => {
const card = { kind: 'lora', name: 'Foo', triggers: ['bar'] };
const gen = { prompt: 'test', actions: ['generate'] };
assert.equal(isCardObject(card), true);
assert.equal(isPatchObject(card), false);
assert.equal(isPatchObject(gen), true);
});
it('normalizePatch aliases civitai_query', () => {
const p = normalizePatch({ civitai_query: 'anime style' });
assert.equal(p.search_query, 'anime style');
it('normalizePatch maps legacy actions generate', () => {
const p = normalizePatch({ prompt: 'x', actions: ['generate'] });
assert.equal(p.generate, true);
});
it('scheduler-only patch is detected with full key list', () => {
setPatchKeys(['prompt', 'scheduler']);
setPatchKeys(['prompt', 'scheduler', 'generate', 'ask']);
assert.equal(isPatchObject({ scheduler: 'euler' }), true);
});
});
describe('session.js', () => {
it('mergeDelta is sparse and keeps other fields', () => {
let s = emptySession();
s.gen.prompt = 'old';
s.gen.steps = 8;
s = mergeDelta(s, { aspect: '16:9', generate: true });
assert.equal(s.gen.prompt, 'old');
assert.equal(s.gen.steps, 8);
assert.equal(s.gen.aspect, '16:9');
assert.equal(patchWantsGenerate({ generate: true }), true);
assert.equal(patchWantsGenerate({ actions: ['generate'] }), true);
});
it('legacy flat params upgrade to session shape', () => {
const s = sessionFromLegacyParams({
prompt: 'fox',
steps: 28,
loras: [{ name: 'a', weight: 0.8 }],
genResults: [{ id: 'var1', src: '/View/x.png' }],
persona: 'leonid',
});
assert.equal(s.gen.prompt, 'fox');
assert.equal(s.gen.steps, 28);
assert.equal(s.board.genResults.length, 1);
const blob = toPersistParams(s);
assert.ok(blob.gen);
assert.ok(blob.board);
});
it('resolveTurnIntent vetoes generate', () => {
const intent = resolveTurnIntent(
{ generate: true, prompt: 'x' },
'не генерируй',
{ vetoFn: (t) => /не\s+генерир/i.test(t) },
);
assert.equal(intent.generate, false);
assert.equal(intent.vetoed, true);
});
});