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:
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user