A PHP client for Hindsight, the open-source memory server for AI agents, and long-term memory for Laravel AI SDK agents.
A Laravel AI agent forgets everything when the conversation ends. With one middleware it remembers: before it answers, it recalls what it knows about the user; after it answers, it stores the exchange.
use Fedoseev\Hindsight\Laravel\Ai\Memory;
class SupportAgent implements Agent, HasMiddleware
{
use Promptable;
public function __construct(private User $user) {}
public function instructions(): string
{
return 'You are a friendly support agent.';
}
public function middleware(): array
{
return [Memory::bank("user-{$this->user->id}")];
}
}Monday: "I'm on the Pro plan and I prefer short answers." Friday, in a new conversation: "When does my plan renew?" The agent already knows the plan and answers briefly.
- Memory that waits out an outage. With
->queue(), each exchange is stored by a queued job that retries while the Hindsight API is unavailable. Every attempt sends the same operation ID, so a retry never stores a memory twice. - Tests without a server.
Hindsight::fake()replaces the client with an in-memory server and gives youassertRetained()andassertRecalled(). CI compares its tag filters, document handling and rejected requests with real Hindsight servers.
Why not RemembersConversations? The Laravel AI SDK's own conversation memory replays the latest messages of one conversation, up to 100 by default (maxConversationMessages() in laravel/ai 1.3). A new conversation starts empty, and a long one loses its beginning. Hindsight keeps facts across conversations and recalls only the ones relevant to the current message. The two work together: RemembersConversations for the thread, Memory for what the agent should know about the user.
This is a community package. It is not affiliated with Vectorize, the company behind Hindsight.
- Installation
- Run a Hindsight server
- The client
- Laravel
- Memory for Laravel AI SDK agents
- Queued retains
- Errors
- Testing your application
- How this package is tested
- Compatibility
composer require fedoseev/hindsight-phpUntil the package is listed on Packagist, tell Composer where to find it first:
composer config repositories.hindsight-php vcs https://github.com/skybleak333/hindsight-php
composer require fedoseev/hindsight-php:^0.3The client needs PHP 8.2+ and works in any PHP project. The Laravel integration is discovered automatically. The agent memory needs laravel/ai 1.x and PHP 8.3+.
The quickest way is Docker. It starts the API on port 8888 and the web UI on port 9999:
docker run -it --pull always --name hindsight -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v hindsight-data:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latestHindsight uses an LLM to turn what you store into facts. It supports OpenAI, Anthropic, Gemini, Groq, Ollama and many other providers. See the installation guide for all options, or use Hindsight Cloud at https://api.hindsight.vectorize.io.
use Fedoseev\Hindsight\Hindsight;
$hindsight = new Hindsight('http://localhost:8888', apiKey: getenv('HINDSIGHT_API_KEY') ?: null);
// Store a memory. The server extracts facts, entities and dates from it.
$hindsight->retain('my-bank', 'Alice works at Google as a software engineer.');
// Find what is relevant to a question.
$memories = $hindsight->recall('my-bank', 'What does Alice do?');
foreach ($memories as $memory) {
echo $memory->text; // "Alice works at Google as a software engineer"
}
// Let the server reason over everything it remembers.
echo $hindsight->reflect('my-bank', 'Tell me about Alice');You do not need to create a bank first: retain and other writes create a missing bank with default settings. Reads of a bank nobody has written to answer 404, and the agent middleware and tools treat that as "no memories yet". Method names and arguments follow the official Python and TypeScript clients, so the Hindsight documentation applies directly.
use Fedoseev\Hindsight\Hindsight;
$hindsight->retain(
'support',
'The customer asked to move the delivery to Friday.',
timestamp: new DateTimeImmutable('2026-10-28 14:00'),
context: 'support chat',
documentId: 'ticket-4521',
tags: ['user:42'],
metadata: ['channel' => 'chat'],
);
// Many items in one request. Keys can be camelCase or the API's snake_case.
$hindsight->retainBatch('support', [
['content' => 'Prefers email over phone.', 'tags' => ['user:42']],
['content' => 'Lives in Rotterdam.', 'tags' => ['user:42'], 'documentId' => 'profile-42'],
]);Retain calls the server's LLM, so it can take a few seconds. Pass async: true to return at once and let the server work in the background:
$response = $hindsight->retain('support', $transcript, async: true);
// Later, or in a queued job:
$operation = $hindsight->waitForOperation('support', $response->operationId, timeout: 60);waitForOperation() throws OperationFailedException if the operation fails and TimeoutException if it is still running after the timeout. When an async batch mixes items with different retain strategies, the server splits it into several operations: then operationId is null and the IDs are in operationIds.
use Fedoseev\Hindsight\Budget;
use Fedoseev\Hindsight\TagsMatch;
$memories = $hindsight->recall(
'support',
'How does this customer want to be contacted?',
budget: Budget::High, // how hard to search: Low, Mid (default) or High
maxTokens: 2048, // upper bound for the size of the results
types: ['world', 'experience'],
tags: ['user:42'],
tagsMatch: TagsMatch::AnyStrict, // only memories tagged user:42
);
$memories->texts(); // ['Prefers email over phone', ...]
$memories->toPrompt(); // "- Prefers email over phone\n- Lives in Rotterdam" for an LLM prompt
$memories->isEmpty();
$first = $memories->results[0];
$first->id; $first->text; $first->type; $first->occurredStart; $first->documentId; $first->tags;Any other recall option goes into options, with the API's field names: options: ['include' => ['chunks' => ['max_tokens' => 2000]]].
$answer = $hindsight->reflect('support', 'Summarise this customer', tags: ['user:42'], includeFacts: true);
$answer->text; // Markdown
$answer->basedOn; // the memories the answer used
// Structured output
$answer = $hindsight->reflect('support', 'Where does the customer live?', responseSchema: [
'type' => 'object',
'properties' => ['city' => ['type' => 'string']],
'required' => ['city'],
]);
$answer->structuredOutput; // ['city' => 'Rotterdam']| Area | Methods |
|---|---|
| Banks | listBanks, createBank, updateBank, getBankStats, deleteBank, getBankConfig, updateBankConfig, resetBankConfig |
| Bank aliases | listBankAliases, addBankAlias, setPrimaryBankAlias, removeBankAlias |
| Memories | listMemories, getMemory, clearMemories |
| Documents | listDocuments, getDocument, deleteDocument |
| Directives | createDirective, listDirectives, getDirective, updateDirective, deleteDirective |
| Mental models | createMentalModel, listMentalModels, getMentalModel, updateMentalModel, deleteMentalModel, refreshMentalModel |
| Operations | getOperation, listOperations, cancelOperation, retryOperation, waitForOperation |
| Server | health, version |
Bank settings such as the reflect mission and disposition live in the bank configuration since Hindsight 1.1 (the old profile endpoints answer 410 Gone):
$hindsight->createBank('support', name: 'Support', reflectMission: 'Help customers of an online shop.');
$hindsight->updateBankConfig('support', ['disposition_empathy' => 5]);An alias is another id that reaches the same bank, so a bank can be renamed while clients move to the new id one by one. A failed operation, for example a retain that hit the LLM provider's rate limit, can be queued again:
$hindsight->addBankAlias('support', 'helpdesk'); // requests to "helpdesk" now reach "support"
$hindsight->retryOperation('support', $failed->id);For any endpoint without its own method, use request(). It returns the decoded JSON:
$tags = $hindsight->request('GET', '/v1/default/banks/support/tags', query: ['limit' => 50]);The client accepts any PSR-18 HTTP client. Without one it uses Guzzle with a 120-second timeout, because synchronous retain and reflect wait for the server's LLM. For calls on a user's request path, take a copy with a shorter timeout:
$hindsight = new Hindsight('https://api.hindsight.vectorize.io', apiKey: $key, httpClient: $myPsr18Client);
$fast = $hindsight->withTimeout(5); // a client you passed in keeps its own timeoutHindsight accepts parameters it does not know, ignores them and answers 200. A misspelled option such as max_token therefore changes the result without any error. The server does name the ignored parameters in an X-Ignored-Params response header, and this client turns that header into a warning for any PSR-3 logger:
$hindsight = new Hindsight('http://localhost:8888', logger: $logger);
$hindsight->recall('support', 'plan', options: ['max_token' => 500]);
// warning: Hindsight ignored unknown parameters: max_token (POST /v1/default/banks/support/memories/recall)In Laravel the warning goes to your default log channel without any setup.
Add the server to .env:
HINDSIGHT_URL=http://localhost:8888
HINDSIGHT_API_KEY=Check the connection:
php artisan hindsight:status
php artisan hindsight:status --bank=supportThe client is a singleton in the container. Inject Fedoseev\Hindsight\Hindsight or use the facade:
use Fedoseev\Hindsight\Laravel\Facades\Hindsight;
Hindsight::retain("user-{$user->id}", 'Upgraded to the Pro plan.', tags: ['billing']);To change the timeout or add headers, publish the config:
php artisan vendor:publish --tag=hindsight-configTo add retries, a proxy or logging, bind your own PSR-18 client as hindsight.http before the client is first used.
The package ships a Laravel Boost guideline and an agent skill, hindsight-php-agent-memory, so a coding agent in your project knows how to add memory to an agent, queue retains and test with Hindsight::fake(). Run php artisan boost:install and pick fedoseev/hindsight-php from the third-party list, or add it to packages in boost.json. Boost then adds the guideline to your agent's guideline file (for example CLAUDE.md) and copies the skill into the agent's skills directory.
Memory is an agent middleware. On every run it does two things:
-
Before the first step, it recalls memories relevant to the user's message and adds them to the agent's instructions:
You are a friendly support agent. # Relevant Memories The following information from long-term memory may be relevant: - Alice is on the Pro plan (2026-09-01) - Alice prefers short answersThe memories stay in the instructions for every step of the run, including the steps after tool calls.
-
After the step that ends the run, it retains the exchange as a short transcript: a
USER:line with the message and anASSISTANT:line with the answer. It uses the same rule as the Laravel AI loop to decide that a run has ended. A step that asks for tools is never stored, so a run that pauses for tool approval is stored once, when it finishes. Streamed answers are stored once the stream has been read. For agents with structured output, theASSISTANT:line is the JSON the model returned.
public function middleware(): array
{
return [
Memory::bank('support')
->tags(["user:{$this->user->id}"]) // only this user's memories, and tag what is stored
->recallBudget(Budget::Mid, maxTokens: 2048) // default: Low, 1024 tokens
->document("conversation-{$this->conversationId}") // append each exchange to one document
->context('Support chat on example.com'), // helps the server extract better facts
];
}| Method | What it does |
|---|---|
recallBudget($budget, $maxTokens, $types) |
How hard recall searches and how much it may add. Default Budget::Low and 1,024 tokens, to keep latency down. |
tags($tags, $match) |
Recall only memories with these tags and tag the stored exchanges. Untagged memories are left out by default (TagsMatch::AnyStrict), so a bank shared by many users does not mix them up. Pass TagsMatch::Any to include untagged memories, such as knowledge every user should get. |
document($id) |
Append every exchange to one document (update_mode: append) instead of storing each separately. |
context($text) |
Describe the source of the conversations. |
withoutRecall() / withoutRetain() |
Use only one half. |
retainSynchronously() |
Wait for fact extraction. By default retains are async, so the user does not wait. |
queue($connection, $queue) |
Store each exchange from a queue worker, with retries. See Queued retains. |
timeout($seconds) |
How long to wait for Hindsight. Default 10 seconds for recall and async retains; a synchronous retain uses the client's timeout. |
throwOnError() |
Fail the run when Hindsight fails. See below. |
using($client) |
Use a specific client instead of the container's. |
Memory is best effort by default. If Hindsight is down, too slow or answers with an error, the middleware reports the exception through Laravel's exception handler and the agent answers without memory. A hung server costs at most the 10-second timeout, not the client's 120 seconds. If you bound your own HTTP client as hindsight.http, its own timeout applies instead, so give it one. Call throwOnError() if a run without memory is worse than no run.
List Memory first in middleware(). Then it sees each step as the model finally answers it, even when another middleware asks the model again.
Sometimes the agent should decide itself when to look something up or what is worth keeping. Give it the tools:
use Fedoseev\Hindsight\Laravel\Ai\Tools\Recall;
use Fedoseev\Hindsight\Laravel\Ai\Tools\Reflect;
use Fedoseev\Hindsight\Laravel\Ai\Tools\Retain;
public function tools(): iterable
{
$bank = "user-{$this->user->id}";
return [
Recall::bank($bank), // recall_memory: search memory
Retain::bank($bank), // retain_memory: store a fact the agent finds important
Reflect::bank($bank), // reflect_memory: reason over everything in memory
];
}Each tool takes tags() (strict by default, like the middleware), timeout(), using() and named() (to rename it, for example when an agent has tools for two banks). Recall and Reflect take budget(). The default timeouts are 15 seconds for Recall and Retain and 60 for Reflect, which runs the server's LLM. When Hindsight fails, a tool returns the error to the model instead of ending the run, so the agent can still answer.
The middleware and the tools work together. A common setup is the middleware for automatic memory plus Retain for facts the agent should keep on purpose.
By default the middleware stores each exchange during the run, and when the Hindsight API is unavailable that exchange is lost: the error is reported and the agent goes on. Add queue() and the exchange is stored by a queued job instead:
public function middleware(): array
{
return [Memory::bank("user-{$this->user->id}")->queue()];
}The job is Fedoseev\Hindsight\Laravel\Jobs\RetainMemory. You can dispatch it yourself, for example from an event listener:
use Fedoseev\Hindsight\Laravel\Jobs\RetainMemory;
RetainMemory::dispatch("user-{$user->id}", 'Upgraded to the Pro plan.', tags: ['billing']);What the job does:
- Retries while the Hindsight API is unavailable. A refused connection, a timeout,
408,429or a5xxanswer sends the job back to the queue: six attempts, 10 seconds, 1, 5, 15 and 15 minutes apart, so a memory gets through an outage of about half an hour. Attempts before the last go back with a warning in the log; only a failure on the last attempt is reported, so an outage does not send six exceptions per memory to your error tracker. - Never stores a memory twice. The job gets an operation ID when it is dispatched and sends it with every attempt. Hindsight treats an async retain with an operation ID it has already seen as the same operation, so a retry after a lost answer adds nothing. That includes two attempts at the same moment: Hindsight's own tests cover simultaneous submissions with one ID. The ID comes from
random_bytes(), notStr::uuid(), soStr::freezeUuids()in your tests does not give every job the same one. - Keeps the time of the conversation. The job sends the time it was dispatched as the memory's timestamp, so an exchange that waited in the queue during an outage is not dated by the moment a worker got to it.
- Fails at once when a retry cannot help. A wrong API key (
401/403), a rejected request (other4xx) or a malformedHINDSIGHT_URLfails the job without retries and reports the exception, so it reaches your error tracker and not only thefailed_jobstable.
The job is done once Hindsight has accepted the retain. Fact extraction then runs on the server, and if it fails there, for example because the server's LLM provider is down, the server retries a few times and marks the operation failed. The job cannot see that. Find such operations with listOperations($bank, status: 'failed') and queue them again with retryOperation().
Set the connection, queue and retry policy in config/hindsight.php, or pass the connection and queue to queue():
HINDSIGHT_QUEUE_CONNECTION=redis
HINDSIGHT_QUEUE=memory
HINDSIGHT_QUEUE_TRIES=6
HINDSIGHT_QUEUE_ENCRYPT=true # the job carries the conversation; encrypt it in the queue with APP_KEYMemory::bank('support')->queue('redis', 'memory');Some combinations behave differently:
- Queued retains are always async on the server, so
queue()andretainSynchronously()cannot be combined. - The worker gets the client from the container, so
queue()cannot be combined withusing(). Bind the client you need asFedoseev\Hindsight\Hindsightinstead. - On the
syncqueue driver there are no retries: the job runs once during the run, and its errors are reported, or thrown withthrowOnError(), as withoutqueue(). Thedeferredandbackgrounddrivers are built onsync: they also run the job once, after the response, wherethrowOnError()has no effect. - With
document(), exchanges of one conversation can be appended out of order when an attempt is retried or several workers run. Each exchange is sent with the time it was dispatched.
Every exception implements Fedoseev\Hindsight\Exceptions\HindsightException.
| Exception | When |
|---|---|
ConfigurationException |
The URL is not an http(s) URL. Thrown when the client is created. |
ConnectionException |
The server could not be reached or did not answer in time. |
AuthenticationException |
401 or 403: the API key is missing or wrong. |
NotFoundException |
404: the bank, document or other resource does not exist. |
ValidationException |
422: the server rejected the request. errors() lists the fields. |
ApiException |
Any other error status. Has statusCode and body. The three above extend it. |
UnexpectedResponseException |
A success status with a body that is not JSON, for example when the URL points at the web UI. |
OperationFailedException, TimeoutException |
From waitForOperation(). |
The message always contains the server's explanation:
Hindsight API error 422: body.items.0.content: Field required
Hindsight::fake() replaces the client with an in-memory server. Seed what recall should find, run your code, then assert what was stored and searched:
use Fedoseev\Hindsight\Laravel\Facades\Hindsight;
public function test_the_agent_uses_what_it_remembers(): void
{
Hindsight::fake([
'user-1' => ['Alice is on the Pro plan'],
]);
SupportAgent::fake(['Your Pro plan renews on 1 December.']);
(new SupportAgent($alice))->prompt('When does my plan renew?');
Hindsight::assertRecalled('user-1', fn (string $query) => $query === 'When does my plan renew?');
Hindsight::assertRetained('user-1', fn (string $content) => str_contains($content, 'renews on 1 December'));
}The middleware, the tools and the RetainMemory job get the client from the container, so they all use the fake. Outside Laravel, create new HindsightFake([...]) from Fedoseev\Hindsight\Testing and pass it wherever you pass the client.
| Method | What it does |
|---|---|
assertRetained($bank, $callback) |
A memory was stored in the bank. The callback gets the content and the item with the API's keys (tags, document_id, …) and returns whether it is the one; any truthy value counts. Pass an integer instead to assert how many. |
assertNotRetained($bank, $callback), assertNothingRetained() |
The opposite. |
assertRecalled($bank, $callback), assertNotRecalled($bank) |
The bank was searched. The callback gets the query and the request. |
assertReflected($bank, $callback) |
Reflect was called on the bank. |
retained($bank), recalls($bank) |
What was stored and searched, to inspect it yourself. |
remember($bank, ...$memories) |
Seed more memories. A memory is its text, or an array such as ['text' => '…', 'tags' => ['user:1'], 'type' => 'experience']. |
reflectUsing($answer) |
What reflect answers: a string, or a closure that gets the query and the bank. |
unavailable($times, $status), available() |
Make requests fail as if Hindsight were down: a refused connection, or $status such as 503. Without $times, until available(). |
respondTo($method, $path, $response) |
Answer an endpoint the fake does not simulate. * matches one path segment. |
The fake follows the server where a test depends on it:
- A bank nobody wrote to answers
404with the server's message, so you test the first conversation of a new user as it will happen. - Tag filters (
any,all,any_strict,all_strict,exact) select the same memories as on the server. - A retain with a
document_idreplaces that document unless it appends. - An async retain repeated with the same operation ID stores nothing new, and an operation ID used in another bank answers
409. - These retains are rejected with the server's status:
422for an operation ID that is not a UUID, a timestamp that is not ISO 8601 (orunset), a tag or metadata value that is not a string, or an unknownupdate_mode, and400for an append without adocument_id. Other invalid fields are not checked.
It is simpler than the server on purpose: a stored item becomes one memory with its text as sent (no fact extraction), recall returns every memory that passes the filters in the order they were stored (no ranking, no token budget), and async work is finished at once. An endpoint the fake does not simulate throws a LogicException instead of answering something made up.
To check a single HTTP call yourself, bind any PSR-18 client as hindsight.http before the client is first used, for example Guzzle's MockHandler.
- Every request in the unit tests is checked against Hindsight's own OpenAPI document, downloaded from the
v1.1.0tag. The check is stricter than the server: Hindsight answers200for unknown fields, so a misspelled field name in the client would never fail a request. The tests reject it. Response fixtures are checked against the same document. - CI starts real Hindsight 1.1.0 and 1.2.0 servers (
hindsight-api-slimwith local ONNX embeddings and no LLM) and runs the integration suite against it, including a Laravel AI agent that remembers a fact from one conversation in the next. The job runs on every push and weekly, so a new Hindsight release that breaks something shows up quickly. - The fake is checked against the same OpenAPI document and against real servers. A unit test sends a call to every endpoint the fake simulates and validates each request and successful answer against the schema. The integration suite runs the same tag filters, document replacement, missing bank, rejected retains and accepted timestamps on the fake and on real Hindsight 1.1.0 and 1.2.0, and compares the results.
- Queued retains run through Laravel's
queue:work --oncecommand on the database queue driver, inside the test process with time travel: a job released while Hindsight answers503is stored once it is back, a retry after a lost answer sends the same operation ID, only the last failed attempt is reported, and a401fails the job without retries. Against a real server, a job run twice is one operation and one memory, while the same async retain sent twice without an operation ID stores two copies. - PHPStan at level 9. The client reads response fields without trusting their types, so an unexpected value from the server becomes
nullinstead of aTypeError.
Without an LLM the server cannot run reflect or mental models, so those are covered by the OpenAPI-checked unit tests only.
| Versions | Tested in CI | |
|---|---|---|
| PHP | 8.2+ (client), 8.3+ (Laravel AI) | 8.2, 8.3, 8.4, 8.5 |
| Laravel | 12, 13 | 12, 13 |
| Laravel AI SDK | 1.x | latest and lowest allowed |
| Hindsight API | 1.1, 1.2 | 1.1.0, 1.2.0 |
| Guzzle | 7.15+, 8 | 7 and 8 |
Issues and pull requests are welcome. Run the tests with composer test and PHPStan with composer analyse. To run the integration suite, start a Hindsight server on 127.0.0.1:8888 and run composer test:integration. The integration tests create and delete their own banks and refuse to run against any host other than localhost.
MIT. See LICENSE.