Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/sdk/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,14 @@ questions:
- When was bulk remember added to the Walrus Memory SDK?
- What security improvements have been made to the MemWal SDK?
answer: >-
Version 0.1.13 adds degraded recall flags (degraded and dropped_count on RecallResult, returned by default via onDropped: "ignore"), opt-in MemWalRecallAllDroppedError when onDropped: "throw" is passed, aligns MemWal and MemWalMock input validation, rejects invalid idempotencyKey values before signing and non-positive limits consistently, and preserves access to whitespace namespaces on read paths while rejecting empty namespace strings. Version 0.1.12 bounds withMemWal recall queries (preserving both head context and tail question/instructions via strategy: "head-tail") to 16,384 UTF-8 bytes and auto-save analyze inputs to 65,536 UTF-8 bytes without splitting multi-byte UTF-8 codepoints across boundaries, preventing HTTP 400 errors on long turns. Version 0.1.11 adds opt-in content-hash deduplication (dedupe: true) for remember(), copies caller-supplied Uint8Array keys on client creation, hardens MemWalManual.destroy() secret cleanup and client invalidation, validates and clamps recalled memory distance before similarity relevance filtering and formatting, validates listNamespaces and listMemories limits before signing, adds AbortSignal cancellation support to analyze() via AnalyzeOptions.signal, and validates manual-mode vector and encryptedData inputs before signing.
Version 0.1.13 adds ask() on MemWal and MemWalMock for memory-backed question answering via POST /api/ask with object-style and positional overloads and input validation, adds degraded recall flags (degraded and dropped_count on RecallResult, returned by default via onDropped: "ignore"), opt-in MemWalRecallAllDroppedError when onDropped: "throw" is passed, aligns MemWal and MemWalMock input validation, rejects invalid idempotencyKey values before signing and non-positive limits consistently, and preserves access to whitespace namespaces on read paths while rejecting empty namespace strings. Version 0.1.12 bounds withMemWal recall queries (preserving both head context and tail question/instructions via strategy: "head-tail") to 16,384 UTF-8 bytes and auto-save analyze inputs to 65,536 UTF-8 bytes without splitting multi-byte UTF-8 codepoints across boundaries, preventing HTTP 400 errors on long turns. Version 0.1.11 adds opt-in content-hash deduplication (dedupe: true) for remember(), copies caller-supplied Uint8Array keys on client creation, hardens MemWalManual.destroy() secret cleanup and client invalidation, validates and clamps recalled memory distance before similarity relevance filtering and formatting, validates listNamespaces and listMemories limits before signing, adds AbortSignal cancellation support to analyze() via AnalyzeOptions.signal, and validates manual-mode vector and encryptedData inputs before signing.
---

## 0.1.13

### Added

- `ask()` on `MemWal` and `MemWalMock` wraps `POST /api/ask` and returns `AskResult` (`answer`, `memories_used`, `memories`), supporting both object-style `ask({ question, limit, namespace, scoringWeights, timeoutMs })` and positional `ask(question, limit?, namespace?)` / `ask(question, options?)` while validating `question`, `namespace`, `limit`, `scoringWeights`, and the response `memories` array before returning. (#1067)
- `RecallResult` includes an optional `degraded?: boolean` flag set to `true` whenever one or more vector matches were dropped, and `recall()` returns the degraded result (`results: []`, `degraded: true`, `dropped_count`) by default when all matches fail to download or decrypt. (#1036, #1070)
- `RecallOptions` accepts `onDropped?: "ignore" | "throw"` (defaulting to `"ignore"`). Passing `onDropped: "throw"` throws `MemWalRecallAllDroppedError` (`name = "MemWalRecallAllDropped"`, `code = "RECALL_ALL_DROPPED"`, exposing `total`, `droppedCount`, and `namespace`) when every vector search match is dropped during Walrus blob download or SEAL decryption. (#1036, #1070)

Expand Down
1 change: 1 addition & 0 deletions packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- `ask()` on `MemWal` and `MemWalMock` wraps `POST /api/ask` and returns `AskResult` (`answer`, `memories_used`, `memories`), supporting both object-style `ask({ question, limit, namespace, scoringWeights, timeoutMs })` and positional `ask(question, limit?, namespace?)` / `ask(question, options?)` while validating `question`, `namespace`, `limit`, `scoringWeights`, and the response `memories` array before returning. (#1067)
- `RecallResult` includes an optional `degraded?: boolean` flag set to `true` whenever one or more vector matches were dropped, and `recall()` returns the degraded result (`results: []`, `degraded: true`, `dropped_count`) by default when all matches fail to download or decrypt. (#1036, #1070)
- `RecallOptions` accepts `onDropped?: "ignore" | "throw"` (defaulting to `"ignore"`). Passing `onDropped: "throw"` throws `MemWalRecallAllDroppedError` (`name = "MemWalRecallAllDropped"`, `code = "RECALL_ALL_DROPPED"`, exposing `total`, `droppedCount`, and `namespace`) when every vector search match is dropped during Walrus blob download or SEAL decryption. (#1036, #1070)

Expand Down
6 changes: 5 additions & 1 deletion packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ const memories = await memwal.recall({
topK: 10,
maxDistance: 0.8,
});
const asked = await memwal.ask({
question: "What are the user's preferences?",
limit: 5,
});
await memwal.restore("demo");
```

Expand All @@ -81,7 +85,7 @@ const result = await memwal.recall({ query: "display preference" });
expect(result.results[0].text).toContain("dark mode");
```

The mock supports remember/job polling, bulk remember, recall, analyze, embed, health, restore, `forget(blobId)`, and `clear(namespace?)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.
The mock supports remember/job polling, bulk remember, recall, ask, analyze, embed, health, restore, `forget(blobId)`, and `clear(namespace?)`. For deterministic behavior, `analyze` stores its full input as one fact instead of invoking an LLM extractor. Its simple relevance score is for application tests, not production search-quality evaluation.

## Exports

Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export type {
TruncationStrategy,
ScoringWeights,
EmbedResult,
AskMemory,
AskResult,
AskOptions,
AskParams,
AnalyzeOptions,
AnalyzeResult,
AnalyzeWaitResult,
Expand Down
68 changes: 67 additions & 1 deletion packages/sdk/src/memwal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import type {
RecallOptions,
RecallParams,
EmbedResult,
AskMemory,
AskResult,
AskOptions,
AskParams,
AnalyzeOptions,
AnalyzeResult,
AnalyzeWaitResult,
Expand Down Expand Up @@ -93,7 +97,7 @@ import {
} from "./compatibility.js";
import { applyTokenBudget, estimateTokens, validateMaxTokens } from "./tokens.js";
import { pollingDelayMs } from "./polling-delay.js";
import { resolveRecallCall } from "./recall-args.js";
import { resolveAskCall, resolveRecallCall } from "./recall-args.js";
import {
MemWalRateLimitedError,
MemWalRecallAllDroppedError,
Expand Down Expand Up @@ -1469,6 +1473,68 @@ export class MemWal {
);
}

/**
* Ask a question answered using your memories.
*
* The server recalls relevant memories, decrypts them using the SEAL
* session credential, injects them as context, and calls an LLM to
* produce a personalized answer.
*
* Supports both object-style `ask({ question, limit, namespace, scoringWeights })`
* and positional `ask(question, limit?, namespace?)` / `ask(question, options?)`.
*
* @returns AskResult with answer, memories_used count, and memories list
*
* @example
* ```typescript
* const result = await memwal.ask({
* question: "What food should I avoid?",
* limit: 5,
* namespace: "profile",
* });
* console.log(result.answer);
* ```
*/
async ask(params: AskParams): Promise<AskResult>;
async ask(question: string, options: AskOptions): Promise<AskResult>;
async ask(
question: string,
limit?: number,
namespace?: string,
): Promise<AskResult>;
async ask(
questionOrParams: string | AskParams,
limitOrOptions: number | AskOptions | undefined = 5,
namespace?: string,
): Promise<AskResult> {
const { question, options } = resolveAskCall(
questionOrParams,
limitOrOptions,
namespace,
);
assertNonBlankText(question, "question cannot be empty");
const resolvedNamespace =
options.namespace === undefined ? this.namespace : options.namespace;
assertNonBlankText(resolvedNamespace, "namespace cannot be empty");
const limit = options.limit !== undefined ? options.limit : 5;
assertPositiveIntegerLimit(limit);
assertFiniteScoringWeights(options.scoringWeights);

const result = await this.signedRequest<AskResult>(
"POST",
"/api/ask",
{
question,
limit,
namespace: resolvedNamespace,
scoring_weights: scoringWeightsToWire(options.scoringWeights),
},
{ timeoutMs: options.timeoutMs },
);
requireArrayField<AskMemory>(result, "memories", "POST /api/ask");
return result;
}

/**
* Analyze conversation text and return as soon as extracted facts are accepted.
*
Expand Down
52 changes: 51 additions & 1 deletion packages/sdk/src/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type {
AnalyzeOptions,
AnalyzeResult,
AnalyzeWaitResult,
AskMemory,
AskOptions,
AskParams,
AskResult,
EmbedResult,
HealthResult,
RecallOptions,
Expand All @@ -25,7 +29,7 @@ import type {
ListNamespacesOptions,
} from "./types.js";
import { applyTokenBudget, estimateTokens, validateMaxTokens } from "./tokens.js";
import { resolveRecallCall } from "./recall-args.js";
import { resolveAskCall, resolveRecallCall } from "./recall-args.js";
import {
assertFiniteScoringWeights,
assertNonBlankText,
Expand Down Expand Up @@ -556,6 +560,52 @@ export class MemWalMock {
return { vector: vector.map((value) => value / magnitude) };
}

async ask(params: AskParams): Promise<AskResult>;
async ask(question: string, options: AskOptions): Promise<AskResult>;
async ask(
question: string,
limit?: number,
namespace?: string,
): Promise<AskResult>;
async ask(
questionOrParams: string | AskParams,
limitOrOptions: number | AskOptions | undefined = 5,
namespace?: string,
): Promise<AskResult> {
const { question, options } = resolveAskCall(
questionOrParams,
limitOrOptions,
namespace,
);
assertNonBlankText(question, "question cannot be empty");
const resolvedNamespace =
options.namespace === undefined ? this.namespace : options.namespace;
assertNonBlankText(resolvedNamespace, "namespace cannot be empty");
const limit = options.limit !== undefined ? options.limit : 5;
assertPositiveIntegerLimit(limit);
assertFiniteScoringWeights(options.scoringWeights);

const recalled = await this.recall({
query: question,
limit,
namespace: resolvedNamespace,
scoringWeights: options.scoringWeights,
});
const memories: AskMemory[] = recalled.results.map((memory) => ({
blob_id: memory.blob_id,
text: memory.text,
distance: memory.distance,
}));
return {
answer:
memories.length > 0
? memories.map((m) => m.text).join(" ")
: "No relevant memories found.",
memories_used: memories.length,
memories,
};
}

async restore(namespace: string, limit = 10): Promise<RestoreResult> {
assertReadableNamespace(namespace);
assertPositiveIntegerLimit(limit);
Expand Down
33 changes: 31 additions & 2 deletions packages/sdk/src/recall-args.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RecallOptions, RecallParams } from "./types.js";
import type { AskOptions, AskParams, RecallOptions, RecallParams } from "./types.js";

/** String second argument is a namespace, not options. */
export function resolveLimitOrOptions<T extends { limit?: number; namespace?: string }>(
Expand Down Expand Up @@ -26,12 +26,16 @@ export function resolveLimitOrOptions<T extends { limit?: number; namespace?: st
}

function invalidSecondArgMessage(method: string): string {
const firstArg = method === "ask" ? "question" : "query";
const shared =
`${method}() second argument must be a number (limit) or an options object, not a string. ` +
`Namespace belongs in an options object (${method}(query, { namespace })) or as the third argument (${method}(query, limit, namespace)).`;
`Namespace belongs in an options object (${method}(${firstArg}, { namespace })) or as the third argument (${method}(${firstArg}, limit, namespace)).`;
if (method === "recall") {
return `${shared} Preferred form: recall({ query, namespace }).`;
}
if (method === "ask") {
return `${shared} Preferred form: ask({ question, namespace }).`;
}
return shared;
}

Expand Down Expand Up @@ -59,3 +63,28 @@ export function resolveRecallCall(
options: resolveLimitOrOptions("recall", limitOrOptions, namespace),
};
}

/** Normalize object-style and positional `ask()` arguments. */
export function resolveAskCall(
questionOrParams: string | AskParams,
limitOrOptions?: number | AskOptions | null,
namespace?: string,
): { question: string; options: AskOptions } {
if (
questionOrParams !== null &&
typeof questionOrParams === "object" &&
!Array.isArray(questionOrParams)
) {
const { question, ...rest } = questionOrParams;
return { question, options: rest };
}
if (typeof questionOrParams !== "string") {
throw new TypeError(
"ask() first argument must be a question string or an AskParams object.",
);
}
return {
question: questionOrParams,
options: resolveLimitOrOptions("ask", limitOrOptions, namespace, 5),
};
}
34 changes: 34 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,40 @@ export interface EmbedResult {
vector: number[];
}

/** A single memory used as context by ask() */
export interface AskMemory {
blob_id: string;
text: string;
distance: number;
score?: number;
created_at?: string;
}

/** Result from ask() */
export interface AskResult {
answer: string;
memories_used: number;
memories: AskMemory[];
}

/** Options for ask() */
export interface AskOptions {
/** Max memories to use as context (default: 5, relayer caps at 100). Must be a positive integer. */
limit?: number;
/** Namespace override (default: config namespace or "default"). */
namespace?: string;
/** Optional composite-scoring weights applied by the relayer's ranker before injecting memories. */
scoringWeights?: ScoringWeights;
/** Optional request deadline in milliseconds. */
timeoutMs?: number;
}

/** Object-style ask() input. */
export interface AskParams extends AskOptions {
/** The question to ask. */
question: string;
}

/** Options for analyze() / analyzeAndWait(). */
export interface AnalyzeOptions {
/** Override the default namespace for this call. */
Expand Down
Loading
Loading