Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@forwardimpact/libmemory

Package Overview
Dependencies
Maintainers
1
Versions
9
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@forwardimpact/libmemory - npm Package Compare versions

Comparing version
0.1.43
to
0.1.44
+165
src/index.js
import { common, tool } from "@forwardimpact/libtype";
import { getModelBudget } from "./models.js";
// Re-export model utilities
export { getModelBudget } from "./models.js";
/**
* Memory window builder for managing conversation history with budget constraints
* Builds complete message arrays including assistant, tools, and conversation history
*/
export class MemoryWindow {
#resourceId;
#resourceIndex;
#memoryIndex;
/**
* Creates a new MemoryWindow instance for a specific resource
* @param {string} resourceId - The resource ID (conversation ID)
* @param {import("@forwardimpact/libresource").ResourceIndex} resourceIndex - Resource index for loading resources
* @param {import("./index/memory.js").MemoryIndex} memoryIndex - Memory index instance for this specific resource
*/
constructor(resourceId, resourceIndex, memoryIndex) {
if (!resourceId) throw new Error("resourceId is required");
if (!resourceIndex) throw new Error("resourceIndex is required");
if (!memoryIndex) throw new Error("memoryIndex is required");
this.#resourceId = resourceId;
this.#resourceIndex = resourceIndex;
this.#memoryIndex = memoryIndex;
}
/**
* Builds a complete memory window with messages and tools
* @param {string} model - Model name to determine budget
* @param {number} maxTokens - Tokens reserved for LLM output
* @returns {Promise<{messages: object[], tools: object[]}>} Complete window with messages and tools
*/
async build(model, maxTokens) {
if (!model) {
throw new Error("model is required");
}
if (!maxTokens || maxTokens <= 0) {
throw new Error("maxTokens is required and must be positive");
}
const total = getModelBudget(model);
const actor = "common.System.root";
// Load conversation to get agent_id
const [conversation] = await this.#resourceIndex.get(
[this.#resourceId],
actor,
);
if (!conversation) {
throw new Error(`Conversation not found: ${this.#resourceId}`);
}
// Load agent
const [agent] = await this.#resourceIndex.get(
[conversation.agent_id],
actor,
);
if (!agent) {
throw new Error(`Agent not found: ${conversation.agent_id}`);
}
// Load tools from agent configuration
const toolNames = agent.tools || [];
const functionIds = toolNames.map((name) => `tool.ToolFunction.${name}`);
const functions = await this.#resourceIndex.get(functionIds, actor);
// Calculate overhead (agent + tools tokens)
let overhead = agent.id?.tokens || 0;
for (const f of functions) {
overhead += f.id?.tokens || 0;
}
// History budget = total - overhead - reserved output tokens
const historyBudget = Math.max(0, total - overhead - maxTokens);
// Wrap the function in a call object, ensuring OpenAI-compatible parameters
const tools = functions.map((f) => {
// OpenAI requires parameters.properties and parameters.required even if empty
// Protobuf3 omits empty fields, so we must ensure they exist
const params = f.parameters || {};
const normalizedFunction = {
...f,
parameters: {
type: params.type || "object",
properties: params.properties || {},
required: params.required || [],
},
};
return tool.ToolCall.fromObject({
type: "function",
function: normalizedFunction,
});
});
// Get conversation history within budget
const identifiers = await this.#filterByBudget(historyBudget);
// Load full message objects from identifiers
const history = await this.#resourceIndex.get(identifiers, actor);
// Build complete messages array: agent + conversation history
const messages = [
// Create a system message from agent
common.Message.fromObject({
role: "system",
...agent,
}),
...history,
];
return { messages, tools };
}
/**
* Filters memory identifiers by token budget
* @param {number} budget - Token budget
* @returns {Promise<import("@forwardimpact/libtype").resource.Identifier[]>} Filtered identifiers
* @private
*/
async #filterByBudget(budget) {
const allIdentifiers = await this.#memoryIndex.queryItems();
let totalTokens = 0;
const filtered = [];
// Process newest first, then break when budget is reached
for (let i = allIdentifiers.length - 1; i >= 0; i--) {
const identifier = allIdentifiers[i];
if (identifier.tokens === undefined || identifier.tokens === null) {
throw new Error(
`Identifier missing tokens field: ${JSON.stringify(identifier)}`,
);
}
if (totalTokens + identifier.tokens <= budget) {
filtered.unshift(identifier);
totalTokens += identifier.tokens;
} else {
break;
}
}
// Ensure tool call integrity: tool messages must be preceded by assistant message
while (filtered.length > 0 && filtered[0].type === "tool.ToolCallMessage") {
filtered.shift();
}
return filtered;
}
/**
* Appends identifiers to memory in a single operation
* @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Array of identifiers to append
* @returns {Promise<void>}
*/
async append(identifiers) {
if (!identifiers || identifiers.length === 0) return;
await this.#memoryIndex.add(identifiers);
}
}
import { IndexBase } from "@forwardimpact/libindex";
/**
* Memory index for managing conversation memory using JSONL storage
* Extends IndexBase to provide memory-specific operations with deduplication
* Each instance manages memory for a single resource/conversation
* @implements {import("@forwardimpact/libindex").IndexInterface}
*/
export class MemoryIndex extends IndexBase {
/**
* Adds identifiers to memory in a single storage operation
* @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Identifiers to add
* @returns {Promise<void>}
*/
async add(identifiers) {
if (!identifiers || identifiers.length === 0) return;
if (!this.loaded) await this.loadData();
// Build items for index and storage
const items = identifiers.map((identifier) => ({
id: String(identifier),
identifier,
}));
// Update in-memory index
for (const item of items) {
this.index.set(item.id, item);
}
// Single append to storage with all items
const lines = items.map((item) => JSON.stringify(item)).join("\n");
await this.storage().append(this.indexKey, lines);
}
}
/**
* Static map of model names to their context window token budgets
* Seeded from GitHub Models API via `./scripts/env.sh node scripts/models.js`
* @type {Map<string, number>}
*/
export const MODEL_BUDGETS = new Map([
["ai21-labs/ai21-jamba-1.5-large", 262144],
["cohere/cohere-command-a", 131072],
["cohere/cohere-command-r-08-2024", 131072],
["cohere/cohere-command-r-plus-08-2024", 131072],
["deepseek/deepseek-r1", 128000],
["deepseek/deepseek-r1-0528", 128000],
["deepseek/deepseek-v3-0324", 128000],
["meta/llama-3.2-11b-vision-instruct", 128000],
["meta/llama-3.2-90b-vision-instruct", 128000],
["meta/llama-3.3-70b-instruct", 128000],
["meta/llama-4-maverick-17b-128e-instruct-fp8", 1000000],
["meta/llama-4-scout-17b-16e-instruct", 10000000],
["meta/meta-llama-3.1-405b-instruct", 131072],
["meta/meta-llama-3.1-8b-instruct", 131072],
["microsoft/mai-ds-r1", 128000],
["microsoft/phi-4", 16384],
["microsoft/phi-4-mini-instruct", 128000],
["microsoft/phi-4-mini-reasoning", 128000],
["microsoft/phi-4-multimodal-instruct", 128000],
["microsoft/phi-4-reasoning", 32768],
["mistral-ai/codestral-2501", 256000],
["mistral-ai/ministral-3b", 131072],
["mistral-ai/mistral-medium-2505", 128000],
["mistral-ai/mistral-small-2503", 128000],
["openai/gpt-4.1", 1048576],
["openai/gpt-4.1-mini", 1048576],
["openai/gpt-4.1-nano", 1048576],
["openai/gpt-4o", 131072],
["openai/gpt-4o-mini", 131072],
["openai/gpt-5", 200000],
["openai/gpt-5-chat", 200000],
["openai/gpt-5-mini", 200000],
["openai/gpt-5-nano", 200000],
["openai/o1", 200000],
["openai/o1-mini", 128000],
["openai/o1-preview", 128000],
["openai/o3", 200000],
["openai/o3-mini", 200000],
["openai/o4-mini", 200000],
["openai/text-embedding-3-large", 8191],
["openai/text-embedding-3-small", 8191],
["xai/grok-3", 131072],
["xai/grok-3-mini", 131072],
// Test models with specific budgets for unit tests
["test-model-1000", 1000],
["test-model-125", 125],
["test-model-230", 230],
["test-model-300", 300],
]);
/**
* Returns the token budget for a given model
* @param {string} model - Model name with provider prefix (e.g., 'openai/gpt-5')
* @returns {number} Token budget for the model
* @throws {Error} If model is not found in MODEL_BUDGETS
*/
export function getModelBudget(model) {
const budget = MODEL_BUDGETS.get(model);
if (!budget) {
throw new Error(
`Unknown model: ${model}. Known models: ${[...MODEL_BUDGETS.keys()].join(", ")}`,
);
}
return budget;
}
+9
-4
{
"name": "@forwardimpact/libmemory",
"version": "0.1.43",
"version": "0.1.44",
"description": "Memory management library for Guide",

@@ -8,6 +8,6 @@ "license": "Apache-2.0",

"type": "module",
"main": "index.js",
"main": "./src/index.js",
"exports": {
".": "./index.js",
"./index/memory.js": "./index/memory.js"
".": "./src/index.js",
"./index/memory.js": "./src/index/memory.js"
},

@@ -17,2 +17,7 @@ "bin": {

},
"files": [
"src/**/*.js",
"bin/**/*.js",
"README.md"
],
"engines": {

@@ -19,0 +24,0 @@ "bun": ">=1.2.0",

import { common, tool } from "@forwardimpact/libtype";
import { getModelBudget } from "./models.js";
// Re-export model utilities
export { getModelBudget } from "./models.js";
/**
* Memory window builder for managing conversation history with budget constraints
* Builds complete message arrays including assistant, tools, and conversation history
*/
export class MemoryWindow {
#resourceId;
#resourceIndex;
#memoryIndex;
/**
* Creates a new MemoryWindow instance for a specific resource
* @param {string} resourceId - The resource ID (conversation ID)
* @param {import("@forwardimpact/libresource").ResourceIndex} resourceIndex - Resource index for loading resources
* @param {import("./index/memory.js").MemoryIndex} memoryIndex - Memory index instance for this specific resource
*/
constructor(resourceId, resourceIndex, memoryIndex) {
if (!resourceId) throw new Error("resourceId is required");
if (!resourceIndex) throw new Error("resourceIndex is required");
if (!memoryIndex) throw new Error("memoryIndex is required");
this.#resourceId = resourceId;
this.#resourceIndex = resourceIndex;
this.#memoryIndex = memoryIndex;
}
/**
* Builds a complete memory window with messages and tools
* @param {string} model - Model name to determine budget
* @param {number} maxTokens - Tokens reserved for LLM output
* @returns {Promise<{messages: object[], tools: object[]}>} Complete window with messages and tools
*/
async build(model, maxTokens) {
if (!model) {
throw new Error("model is required");
}
if (!maxTokens || maxTokens <= 0) {
throw new Error("maxTokens is required and must be positive");
}
const total = getModelBudget(model);
const actor = "common.System.root";
// Load conversation to get agent_id
const [conversation] = await this.#resourceIndex.get(
[this.#resourceId],
actor,
);
if (!conversation) {
throw new Error(`Conversation not found: ${this.#resourceId}`);
}
// Load agent
const [agent] = await this.#resourceIndex.get(
[conversation.agent_id],
actor,
);
if (!agent) {
throw new Error(`Agent not found: ${conversation.agent_id}`);
}
// Load tools from agent configuration
const toolNames = agent.tools || [];
const functionIds = toolNames.map((name) => `tool.ToolFunction.${name}`);
const functions = await this.#resourceIndex.get(functionIds, actor);
// Calculate overhead (agent + tools tokens)
let overhead = agent.id?.tokens || 0;
for (const f of functions) {
overhead += f.id?.tokens || 0;
}
// History budget = total - overhead - reserved output tokens
const historyBudget = Math.max(0, total - overhead - maxTokens);
// Wrap the function in a call object, ensuring OpenAI-compatible parameters
const tools = functions.map((f) => {
// OpenAI requires parameters.properties and parameters.required even if empty
// Protobuf3 omits empty fields, so we must ensure they exist
const params = f.parameters || {};
const normalizedFunction = {
...f,
parameters: {
type: params.type || "object",
properties: params.properties || {},
required: params.required || [],
},
};
return tool.ToolCall.fromObject({
type: "function",
function: normalizedFunction,
});
});
// Get conversation history within budget
const identifiers = await this.#filterByBudget(historyBudget);
// Load full message objects from identifiers
const history = await this.#resourceIndex.get(identifiers, actor);
// Build complete messages array: agent + conversation history
const messages = [
// Create a system message from agent
common.Message.fromObject({
role: "system",
...agent,
}),
...history,
];
return { messages, tools };
}
/**
* Filters memory identifiers by token budget
* @param {number} budget - Token budget
* @returns {Promise<import("@forwardimpact/libtype").resource.Identifier[]>} Filtered identifiers
* @private
*/
async #filterByBudget(budget) {
const allIdentifiers = await this.#memoryIndex.queryItems();
let totalTokens = 0;
const filtered = [];
// Process newest first, then break when budget is reached
for (let i = allIdentifiers.length - 1; i >= 0; i--) {
const identifier = allIdentifiers[i];
if (identifier.tokens === undefined || identifier.tokens === null) {
throw new Error(
`Identifier missing tokens field: ${JSON.stringify(identifier)}`,
);
}
if (totalTokens + identifier.tokens <= budget) {
filtered.unshift(identifier);
totalTokens += identifier.tokens;
} else {
break;
}
}
// Ensure tool call integrity: tool messages must be preceded by assistant message
while (filtered.length > 0 && filtered[0].type === "tool.ToolCallMessage") {
filtered.shift();
}
return filtered;
}
/**
* Appends identifiers to memory in a single operation
* @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Array of identifiers to append
* @returns {Promise<void>}
*/
async append(identifiers) {
if (!identifiers || identifiers.length === 0) return;
await this.#memoryIndex.add(identifiers);
}
}
import { IndexBase } from "@forwardimpact/libindex";
/**
* Memory index for managing conversation memory using JSONL storage
* Extends IndexBase to provide memory-specific operations with deduplication
* Each instance manages memory for a single resource/conversation
* @implements {import("@forwardimpact/libindex").IndexInterface}
*/
export class MemoryIndex extends IndexBase {
/**
* Adds identifiers to memory in a single storage operation
* @param {import("@forwardimpact/libtype").resource.Identifier[]} identifiers - Identifiers to add
* @returns {Promise<void>}
*/
async add(identifiers) {
if (!identifiers || identifiers.length === 0) return;
if (!this.loaded) await this.loadData();
// Build items for index and storage
const items = identifiers.map((identifier) => ({
id: String(identifier),
identifier,
}));
// Update in-memory index
for (const item of items) {
this.index.set(item.id, item);
}
// Single append to storage with all items
const lines = items.map((item) => JSON.stringify(item)).join("\n");
await this.storage().append(this.indexKey, lines);
}
}
/**
* Static map of model names to their context window token budgets
* Seeded from GitHub Models API via `./scripts/env.sh node scripts/models.js`
* @type {Map<string, number>}
*/
export const MODEL_BUDGETS = new Map([
["ai21-labs/ai21-jamba-1.5-large", 262144],
["cohere/cohere-command-a", 131072],
["cohere/cohere-command-r-08-2024", 131072],
["cohere/cohere-command-r-plus-08-2024", 131072],
["deepseek/deepseek-r1", 128000],
["deepseek/deepseek-r1-0528", 128000],
["deepseek/deepseek-v3-0324", 128000],
["meta/llama-3.2-11b-vision-instruct", 128000],
["meta/llama-3.2-90b-vision-instruct", 128000],
["meta/llama-3.3-70b-instruct", 128000],
["meta/llama-4-maverick-17b-128e-instruct-fp8", 1000000],
["meta/llama-4-scout-17b-16e-instruct", 10000000],
["meta/meta-llama-3.1-405b-instruct", 131072],
["meta/meta-llama-3.1-8b-instruct", 131072],
["microsoft/mai-ds-r1", 128000],
["microsoft/phi-4", 16384],
["microsoft/phi-4-mini-instruct", 128000],
["microsoft/phi-4-mini-reasoning", 128000],
["microsoft/phi-4-multimodal-instruct", 128000],
["microsoft/phi-4-reasoning", 32768],
["mistral-ai/codestral-2501", 256000],
["mistral-ai/ministral-3b", 131072],
["mistral-ai/mistral-medium-2505", 128000],
["mistral-ai/mistral-small-2503", 128000],
["openai/gpt-4.1", 1048576],
["openai/gpt-4.1-mini", 1048576],
["openai/gpt-4.1-nano", 1048576],
["openai/gpt-4o", 131072],
["openai/gpt-4o-mini", 131072],
["openai/gpt-5", 200000],
["openai/gpt-5-chat", 200000],
["openai/gpt-5-mini", 200000],
["openai/gpt-5-nano", 200000],
["openai/o1", 200000],
["openai/o1-mini", 128000],
["openai/o1-preview", 128000],
["openai/o3", 200000],
["openai/o3-mini", 200000],
["openai/o4-mini", 200000],
["openai/text-embedding-3-large", 8191],
["openai/text-embedding-3-small", 8191],
["xai/grok-3", 131072],
["xai/grok-3-mini", 131072],
// Test models with specific budgets for unit tests
["test-model-1000", 1000],
["test-model-125", 125],
["test-model-230", 230],
["test-model-300", 300],
]);
/**
* Returns the token budget for a given model
* @param {string} model - Model name with provider prefix (e.g., 'openai/gpt-5')
* @returns {number} Token budget for the model
* @throws {Error} If model is not found in MODEL_BUDGETS
*/
export function getModelBudget(model) {
const budget = MODEL_BUDGETS.get(model);
if (!budget) {
throw new Error(
`Unknown model: ${model}. Known models: ${[...MODEL_BUDGETS.keys()].join(", ")}`,
);
}
return budget;
}
import { describe, it, beforeEach, mock } from "node:test";
import assert from "node:assert";
import { MemoryIndex } from "../index/memory.js";
import { resource } from "@forwardimpact/libtype";
describe("MemoryIndex - IndexBase Functionality", () => {
let storage;
let memoryIndex;
beforeEach(() => {
storage = {
exists: mock.fn(() => Promise.resolve(false)),
get: mock.fn(() => Promise.resolve([])),
append: mock.fn(() => Promise.resolve()),
};
memoryIndex = new MemoryIndex(storage, "test-conversation.jsonl");
});
describe("Constructor and Properties", () => {
it("should throw error when storage is missing", () => {
assert.throws(() => new MemoryIndex(), /storage is required/);
});
it("should set properties correctly", () => {
const index = new MemoryIndex(storage, "custom.jsonl");
assert.strictEqual(index.storage(), storage, "Should set storage");
assert.strictEqual(index.indexKey, "custom.jsonl", "Should set indexKey");
assert.strictEqual(
index.loaded,
false,
"Should initialize loaded as false",
);
});
it("should use default indexKey when not provided", () => {
const index = new MemoryIndex(storage);
assert.strictEqual(
index.indexKey,
"index.jsonl",
"Should use default indexKey",
);
});
});
describe("Data Loading", () => {
it("should initialize empty index when file doesn't exist", async () => {
storage.exists = mock.fn(() => Promise.resolve(false));
await memoryIndex.loadData();
assert.strictEqual(memoryIndex.loaded, true, "Should mark as loaded");
assert.strictEqual(
storage.exists.mock.callCount(),
1,
"Should check file existence",
);
assert.strictEqual(
storage.get.mock.callCount(),
0,
"Should not try to read non-existent file",
);
});
it("should load existing data from storage", async () => {
const testData = [
{
id: "common.Message.msg1",
identifier: { type: "common.Message", name: "msg1", tokens: 10 },
},
{
id: "common.Message.msg2",
identifier: { type: "common.Message", name: "msg2", tokens: 20 },
},
];
storage.exists = mock.fn(() => Promise.resolve(true));
storage.get = mock.fn(() => Promise.resolve(testData));
await memoryIndex.loadData();
assert.strictEqual(memoryIndex.loaded, true, "Should mark as loaded");
assert.strictEqual(
storage.exists.mock.callCount(),
1,
"Should check file existence",
);
assert.strictEqual(
storage.get.mock.callCount(),
1,
"Should read existing file",
);
// Verify data was loaded into index
assert.strictEqual(
await memoryIndex.has("common.Message.msg1"),
true,
"Should load first item",
);
assert.strictEqual(
await memoryIndex.has("common.Message.msg2"),
true,
"Should load second item",
);
});
it("should be idempotent", async () => {
storage.exists = mock.fn(() => Promise.resolve(false));
await memoryIndex.loadData();
storage.exists.mock.resetCalls();
await memoryIndex.loadData();
assert.strictEqual(
storage.exists.mock.callCount(),
0,
"Should not check existence again when already loaded",
);
assert.strictEqual(memoryIndex.loaded, true, "Should remain loaded");
});
});
describe("Item Management", () => {
it("should return false for non-existent items", async () => {
const exists = await memoryIndex.has("common.Message.nonexistent");
assert.strictEqual(
exists,
false,
"Should return false for non-existent item",
);
});
it("should return true for existing items", async () => {
const identifier = resource.Identifier.fromObject({
type: "common.Message",
name: "test1",
tokens: 10,
});
await memoryIndex.add([identifier]);
const exists = await memoryIndex.has(String(identifier));
assert.strictEqual(exists, true, "Should return true for existing item");
});
it("should add items with correct structure", async () => {
const identifier = resource.Identifier.fromObject({
type: "common.Message",
name: "test1",
tokens: 10,
});
await memoryIndex.add([identifier]);
assert.strictEqual(
storage.append.mock.callCount(),
1,
"Should call storage append",
);
const appendedData = JSON.parse(
storage.append.mock.calls[0].arguments[1],
);
assert.strictEqual(appendedData.id, String(identifier));
assert.strictEqual(appendedData.identifier.name, "test1");
assert.strictEqual(appendedData.identifier.type, "common.Message");
assert.strictEqual(appendedData.identifier.tokens, 10);
});
it("should get items by IDs", async () => {
const identifier = resource.Identifier.fromObject({
type: "common.Message",
name: "test1",
tokens: 10,
});
await memoryIndex.add([identifier]);
const result = await memoryIndex.get([String(identifier)]);
assert.strictEqual(result.length, 1, "Should return one item");
assert.strictEqual(
result[0].name,
"test1",
"Should return correct identifier",
);
assert.strictEqual(
result[0].type,
"common.Message",
"Should return correct type",
);
assert.strictEqual(result[0].tokens, 10, "Should return correct tokens");
});
it("should return empty array for non-existent IDs", async () => {
const result = await memoryIndex.get(["common.Message.nonexistent"]);
assert.strictEqual(
result.length,
0,
"Should return empty array for non-existent item",
);
});
it("should handle null IDs parameter", async () => {
const result = await memoryIndex.get(null);
assert.deepStrictEqual(result, [], "Should return empty array for null");
});
it("should handle empty IDs array", async () => {
const result = await memoryIndex.get([]);
assert.deepStrictEqual(
result,
[],
"Should return empty array for empty array",
);
});
});
describe("Query and Filtering", () => {
beforeEach(async () => {
// Add test items with different types and token counts
const items = [
{ type: "common.Message", name: "msg1", tokens: 10 },
{ type: "common.Message", name: "msg2", tokens: 20 },
{ type: "tool.ToolFunction", name: "func1", tokens: 15 },
{ type: "tool.ToolFunction", name: "func2", tokens: 25 },
{ type: "resource.Document", name: "doc1", tokens: 30 },
];
const identifiers = items.map((item) =>
resource.Identifier.fromObject(item),
);
await memoryIndex.add(identifiers);
});
it("should return all items without filters", async () => {
const results = await memoryIndex.queryItems({});
assert.strictEqual(
results.length,
5,
"Should return all items without filters",
);
});
it("should filter by prefix", async () => {
const messageResults = await memoryIndex.queryItems({
prefix: "common.Message",
});
assert.strictEqual(
messageResults.length,
2,
"Should return only Message items",
);
const toolResults = await memoryIndex.queryItems({
prefix: "tool.ToolFunction",
});
assert.strictEqual(
toolResults.length,
2,
"Should return only ToolFunction items",
);
const noMatchResults = await memoryIndex.queryItems({
prefix: "nonexistent",
});
assert.strictEqual(
noMatchResults.length,
0,
"Should return no items for non-matching prefix",
);
});
it("should apply limit filter", async () => {
const limitedResults = await memoryIndex.queryItems({ limit: 3 });
assert.strictEqual(
limitedResults.length,
3,
"Should return limited items",
);
const zeroLimitResults = await memoryIndex.queryItems({ limit: 0 });
assert.strictEqual(
zeroLimitResults.length,
5,
"Should return all items when limit is 0",
);
});
it("should apply max_tokens filter", async () => {
const tokenLimitedResults = await memoryIndex.queryItems({
max_tokens: 35,
});
assert(
tokenLimitedResults.length >= 1,
"Should return at least one item within token limit",
);
assert(
tokenLimitedResults.length <= 5,
"Should not return more than available items",
);
const strictTokenResults = await memoryIndex.queryItems({
max_tokens: 20,
});
assert.strictEqual(
strictTokenResults.length,
1,
"Should return only first item for strict limit",
);
const veryStrictResults = await memoryIndex.queryItems({
max_tokens: 5,
});
assert.strictEqual(
veryStrictResults.length,
0,
"Should return no items when first exceeds limit",
);
});
it("should apply combined filters", async () => {
const combinedResults = await memoryIndex.queryItems({
prefix: "common.Message",
limit: 1,
max_tokens: 50,
});
assert.strictEqual(
combinedResults.length,
1,
"Should apply all filters together",
);
assert(
String(combinedResults[0]).startsWith("common.Message"),
"Should match prefix filter",
);
});
it("should throw error when identifier is missing tokens field", async () => {
// Add item without tokens by directly manipulating index
const badIdentifier = {
type: "common.Message",
name: "bad",
};
const badItem = {
id: "common.Message.bad",
identifier: badIdentifier,
};
memoryIndex.index.set(badItem.id, badItem);
await assert.rejects(
async () => await memoryIndex.queryItems({ max_tokens: 100 }),
/Identifier missing tokens field/,
"Should throw when tokens field is missing",
);
});
});
describe("Deduplication Behavior", () => {
it("should deduplicate items with same ID", async () => {
const identifier1 = resource.Identifier.fromObject({
name: "msg1",
type: "common.Message",
tokens: 10,
});
const identifier2 = resource.Identifier.fromObject({
name: "msg1",
type: "common.Message",
tokens: 12,
});
await memoryIndex.add([identifier1]);
await memoryIndex.add([identifier2]);
const memory = await memoryIndex.queryItems();
assert.strictEqual(memory.length, 1, "Should have only one item");
assert.strictEqual(memory[0].tokens, 12, "Should keep latest occurrence");
});
});
});
import { test, describe } from "node:test";
import { MemoryWindow } from "../index.js";
import { MemoryIndex } from "../index/memory.js";
import { createPerformanceTest } from "@forwardimpact/libperf";
describe("LibMemory Performance Tests", () => {
/**
* Generate mock identifiers with scores and tokens
* @param {number} count - Number of identifiers to generate
* @param {string} typePrefix - Type prefix for identifiers
* @returns {object[]} Mock identifiers
*/
function generateMockIdentifiers(count, typePrefix = "common.Message") {
const identifiers = [];
for (let i = 0; i < count; i++) {
identifiers.push({
name: `item-${i.toString().padStart(6, "0")}`,
type: typePrefix,
score: Math.random(),
tokens: Math.floor(Math.random() * 500) + 100,
});
}
return identifiers;
}
/**
* Create mock storage and memory index
* @param {number} itemCount - Number of items in memory
* @returns {object} Mock dependencies
*/
function createDependencies(itemCount) {
const items = generateMockIdentifiers(itemCount);
const jsonlData = items
.map((item) => JSON.stringify({ id: item.name, identifier: item }))
.join("\n");
const mockStorage = {
get: (key) => {
if (key === "index.jsonl") {
return Promise.resolve(Buffer.from(jsonlData));
}
return Promise.resolve(Buffer.from(""));
},
exists: (key) => {
if (key === "index.jsonl") return Promise.resolve(true);
return Promise.resolve(false);
},
put: () => Promise.resolve(),
};
return { mockStorage, items };
}
test(
"MemoryWindow.build by window size",
createPerformanceTest({
count: [50, 100, 200, 500],
setupFn: async (windowSize) => {
const { mockStorage } = createDependencies(windowSize);
const memoryIndex = new MemoryIndex(mockStorage, "index.jsonl");
await memoryIndex.loadData();
// Mock resourceIndex for testing
const mockResourceIndex = {
get: async (ids) => {
// Handle conversation lookup
if (ids[0] === "test-conversation") {
return [
{
identifier: { name: "test-conversation" },
descriptor: { name: "Test Conversation" },
type: "common.Conversation",
agent_id: "test-agent",
},
];
}
// Handle agent lookup
if (ids[0] === "test-agent") {
return [
{
identifier: { name: "test-agent" },
descriptor: { name: "Test Agent" },
type: "common.Agent",
instructions: "Test instructions",
tools: [], // No tools to keep it simple
},
];
}
// Handle tool function lookups (return empty array if tools requested)
if (ids[0] && ids[0].startsWith("tool.ToolFunction.")) {
return [];
}
// Handle message lookups (return mock messages based on identifiers)
return ids.map((id, idx) => ({
identifier: { name: id },
role: idx % 2 === 0 ? "user" : "assistant",
content: `Message ${idx}`,
}));
},
};
const memoryWindow = new MemoryWindow(
"test-conversation",
mockResourceIndex,
memoryIndex,
);
const model = "openai/gpt-4.1";
const maxTokens = 4096;
return { memoryWindow, model, maxTokens };
},
testFn: ({ memoryWindow, model, maxTokens }) =>
memoryWindow.build(model, maxTokens),
constraints: {
maxDuration: 120,
maxMemory: 5000,
scaling: "linear",
tolerance: 50, // High tolerance due to small memory footprint causing GC noise
},
}),
);
});
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import { MemoryWindow } from "../index.js";
import { MemoryIndex } from "../index/memory.js";
import { resource, common, tool } from "@forwardimpact/libtype";
import {
MockStorage,
createMockResourceIndex,
} from "@forwardimpact/libharness";
describe("MemoryWindow", () => {
let storage;
let memoryIndex;
let resourceIndex;
let memoryWindow;
beforeEach(() => {
storage = new MockStorage();
memoryIndex = new MemoryIndex(storage, "test-conversation.jsonl");
resourceIndex = createMockResourceIndex({
tools: ["search", "analyze"],
temperature: 0.5,
});
memoryWindow = new MemoryWindow(
"test-conversation",
resourceIndex,
memoryIndex,
);
});
it("should create instance with required parameters", () => {
assert.throws(() => new MemoryWindow(), /resourceId is required/);
assert.throws(() => new MemoryWindow("test"), /resourceIndex is required/);
assert.throws(
() => new MemoryWindow("test", resourceIndex),
/memoryIndex is required/,
);
});
it("should require model parameter", async () => {
const msg = resource.Identifier.fromObject({
name: "msg1",
type: "common.Message",
tokens: 15,
});
await memoryIndex.add([msg]);
await assert.rejects(() => memoryWindow.build(), /model is required/);
await assert.rejects(() => memoryWindow.build(""), /model is required/);
});
it("should require maxTokens parameter", async () => {
await assert.rejects(
() => memoryWindow.build("test-model-1000"),
/maxTokens is required/,
);
await assert.rejects(
() => memoryWindow.build("test-model-1000", 0),
/maxTokens is required/,
);
await assert.rejects(
() => memoryWindow.build("test-model-1000", -1),
/maxTokens is required/,
);
});
it("should return messages and tools structure", async () => {
const msg1 = resource.Identifier.fromObject({
name: "msg1",
type: "common.Message",
tokens: 15,
});
// Add message to resource index so it can be loaded
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "msg1", tokens: 15 },
role: "user",
content: "Hello",
}),
);
await memoryIndex.add([msg1]);
const result = await memoryWindow.build("test-model-1000", 100);
assert.ok(result.messages, "Should have messages array");
assert.ok(result.tools, "Should have tools array");
assert.ok(Array.isArray(result.messages), "messages should be an array");
assert.ok(Array.isArray(result.tools), "tools should be an array");
});
it("should include assistant as first message", async () => {
const result = await memoryWindow.build("test-model-1000", 100);
// First message should be the assistant
assert.ok(result.messages.length >= 1);
assert.strictEqual(result.messages[0].id?.name, "test-agent");
});
it("should include tools from assistant configuration", async () => {
const result = await memoryWindow.build("test-model-1000", 100);
// Should have 2 tools (search and analyze)
assert.strictEqual(result.tools.length, 2);
assert.strictEqual(result.tools[0].function?.name, "search");
assert.strictEqual(result.tools[1].function?.name, "analyze");
});
it("should return all conversation items within budget", async () => {
const msg1 = resource.Identifier.fromObject({
name: "msg1",
type: "common.Message",
tokens: 15,
});
const msg2 = resource.Identifier.fromObject({
name: "msg2",
type: "common.Message",
tokens: 20,
});
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "msg1", tokens: 15 },
role: "user",
content: "Hello",
}),
);
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "msg2", tokens: 20 },
role: "assistant",
content: "Hi there!",
}),
);
await memoryIndex.add([msg1]);
await memoryIndex.add([msg2]);
const result = await memoryWindow.build("test-model-1000", 100);
// Should have assistant + 2 conversation messages
assert.strictEqual(result.messages.length, 3);
assert.strictEqual(result.messages[0].id?.name, "test-agent");
assert.strictEqual(result.messages[1].id?.name, "msg1");
assert.strictEqual(result.messages[2].id?.name, "msg2");
});
it("should build window with budget constraint reserving output tokens", async () => {
const msg1 = resource.Identifier.fromObject({
name: "msg1",
type: "common.Message",
tokens: 15,
});
const msg2 = resource.Identifier.fromObject({
name: "msg2",
type: "common.Message",
tokens: 25,
});
const msg3 = resource.Identifier.fromObject({
name: "msg3",
type: "common.Message",
tokens: 10,
});
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "msg1", tokens: 15 },
role: "user",
content: "First",
}),
);
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "msg2", tokens: 25 },
role: "assistant",
content: "Second",
}),
);
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "msg3", tokens: 10 },
role: "user",
content: "Third",
}),
);
await memoryIndex.add([msg1]);
await memoryIndex.add([msg2]);
await memoryIndex.add([msg3]);
// Budget of 125: assistant(50) + 2 tools(40) = 90 fixed overhead
// Reserve 50 tokens for output (maxTokens parameter)
// historyBudget = 125 - 90 - 50 = -15 (nothing fits)
// But with maxTokens=15: historyBudget = 125 - 90 - 15 = 20, fits msg3(10)
const result = await memoryWindow.build("test-model-125", 15);
// Should be assistant + msg3 only (msg1 and msg2 don't fit)
assert.strictEqual(result.messages.length, 2);
assert.strictEqual(result.messages[0].id?.name, "test-agent");
assert.strictEqual(result.messages[1].id?.name, "msg3");
});
it("should drop orphaned tool messages at start of window", async () => {
// Simulate a conversation where budget cut leaves tool messages without
// their preceding assistant message
const msg1 = resource.Identifier.fromObject({
name: "assistant-with-tool-calls",
type: "common.Message",
tokens: 100,
});
const toolResult1 = resource.Identifier.fromObject({
name: "tool-result-1",
type: "tool.ToolCallMessage",
tokens: 50,
});
const toolResult2 = resource.Identifier.fromObject({
name: "tool-result-2",
type: "tool.ToolCallMessage",
tokens: 50,
});
const msg2 = resource.Identifier.fromObject({
name: "final-assistant",
type: "common.Message",
tokens: 30,
});
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "assistant-with-tool-calls", tokens: 100 },
role: "assistant",
content: "I'll call tools",
}),
);
resourceIndex.addMessage(
tool.ToolCallMessage.fromObject({
id: { name: "tool-result-1", tokens: 50 },
role: "tool",
content: "Result 1",
}),
);
resourceIndex.addMessage(
tool.ToolCallMessage.fromObject({
id: { name: "tool-result-2", tokens: 50 },
role: "tool",
content: "Result 2",
}),
);
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "final-assistant", tokens: 30 },
role: "assistant",
content: "Final response",
}),
);
await memoryIndex.add([msg1]);
await memoryIndex.add([toolResult1]);
await memoryIndex.add([toolResult2]);
await memoryIndex.add([msg2]);
// Budget of 230: assistant(50) + 2 tools(40) = 90 fixed overhead
// Reserve 50 for output: historyBudget = 230 - 90 - 50 = 90
// From newest: msg2(30) + toolResult2(50) = 80, fits
// But toolResult2 is orphaned (no preceding assistant), so drop it
// Continue: msg2(30) fits, but now check toolResult1(50) - orphaned, drop
// Final: just msg2(30)
const result = await memoryWindow.build("test-model-230", 50);
// Should only have assistant + final assistant message (tool messages dropped)
assert.strictEqual(result.messages.length, 2);
assert.strictEqual(result.messages[0].id?.name, "test-agent");
assert.strictEqual(result.messages[1].id?.name, "final-assistant");
});
it("should keep tool messages when preceded by assistant message", async () => {
const msg1 = resource.Identifier.fromObject({
name: "assistant-with-tool-calls",
type: "common.Message",
tokens: 50,
});
const toolResult1 = resource.Identifier.fromObject({
name: "tool-result-1",
type: "tool.ToolCallMessage",
tokens: 30,
});
const msg2 = resource.Identifier.fromObject({
name: "final-assistant",
type: "common.Message",
tokens: 20,
});
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "assistant-with-tool-calls", tokens: 50 },
role: "assistant",
content: "Calling tool",
}),
);
resourceIndex.addMessage(
tool.ToolCallMessage.fromObject({
id: { name: "tool-result-1", tokens: 30 },
role: "tool",
content: "Tool result",
}),
);
resourceIndex.addMessage(
common.Message.fromObject({
id: { name: "final-assistant", tokens: 20 },
role: "assistant",
content: "Done",
}),
);
await memoryIndex.add([msg1]);
await memoryIndex.add([toolResult1]);
await memoryIndex.add([msg2]);
// Budget of 300: assistant(50) + tools(40) = 90 fixed overhead
// Reserve 50 for output: historyBudget = 300 - 90 - 50 = 160
// All 3 messages fit (50+30+20=100)
const result = await memoryWindow.build("test-model-300", 50);
assert.strictEqual(result.messages.length, 4);
assert.strictEqual(result.messages[0].id?.name, "test-agent");
assert.strictEqual(
result.messages[1].id?.name,
"assistant-with-tool-calls",
);
assert.strictEqual(result.messages[2].id?.name, "tool-result-1");
assert.strictEqual(result.messages[3].id?.name, "final-assistant");
});
it("should append identifiers through window", async () => {
const identifiers = [
resource.Identifier.fromObject({
name: "msg1",
type: "common.Message",
tokens: 10,
}),
];
await memoryWindow.append(identifiers);
const fetchedIdentifiers = await memoryIndex.queryItems();
assert.strictEqual(fetchedIdentifiers.length, 1);
assert.strictEqual(fetchedIdentifiers[0].name, "msg1");
});
});