feat: agentmemory v0.1.0 -- persistent memory for AI coding agents

This commit is contained in:
Rohit Ghumare
2026-02-25 12:45:46 +05:30
commit 37de9e5d02
38 changed files with 6353 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.tsbuildinfo
.env
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Rohit Ghumare
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+348
View File
@@ -0,0 +1,348 @@
# agentmemory
Persistent memory for AI coding agents, powered by [iii-engine](https://iii.dev)'s three primitives (Worker / Function / Trigger).
agentmemory captures tool usage during coding sessions, compresses observations via LLM, and injects relevant context into future sessions. Your AI agent remembers what it did yesterday.
## How It Works
```
Session 1: "Add auth to the API"
Claude Code writes code, runs tests, fixes bugs
agentmemory silently captures every tool use
Session ends -> LLM compresses observations into structured memory
Session 2: "Now add rate limiting"
agentmemory injects context from Session 1:
- Auth uses JWT middleware in src/middleware/auth.ts
- Tests in test/auth.test.ts cover token validation
- Decision: chose jose over jsonwebtoken for Edge compatibility
Claude Code starts with full project awareness
```
No manual notes. No copy-pasting. The agent just knows.
## Architecture
agentmemory replaces the traditional memory stack with iii-engine's three primitives:
| Traditional Stack | agentmemory |
|---|---|
| Express.js server | iii Worker + HTTP Triggers |
| SQLite + FTS5 | iii KV State |
| SSE streaming | iii Streams (WebSocket) |
| Process manager (pm2/systemd) | iii-engine manages workers |
| 20+ REST endpoints | API Triggers -> Functions |
**27 source files. 1,808 lines of code. 35KB bundled.**
Scalable from day one -- iii-engine handles orchestration, state persistence, and real-time streaming natively.
## Quick Start
```bash
# 1. Start iii-engine
docker compose up -d
# 2. Install and build
npm install && npm run build
# 3. Start agentmemory worker
npm start
```
That's it. agentmemory connects to iii-engine on `ws://localhost:49134` and exposes REST endpoints on port `3111`.
### Configure Claude Code Hooks
Add to your `~/.claude/settings.json`:
```json
{
"hooks": {
"SessionStart": [{ "type": "command", "command": "node /path/to/agentmemory/dist/hooks/session-start.mjs" }],
"UserPromptSubmit": [{ "type": "command", "command": "node /path/to/agentmemory/dist/hooks/prompt-submit.mjs" }],
"PostToolUse": [{ "type": "command", "command": "node /path/to/agentmemory/dist/hooks/post-tool-use.mjs" }],
"Stop": [{ "type": "command", "command": "node /path/to/agentmemory/dist/hooks/stop.mjs" }],
"SessionEnd": [{ "type": "command", "command": "node /path/to/agentmemory/dist/hooks/session-end.mjs" }]
}
}
```
Or install as a Claude Code plugin (see [Plugin Install](#claude-code-plugin)).
## Observation Pipeline
Every tool use flows through a four-stage pipeline:
```
Claude Code PostToolUse hook
|
v
mem::observe Store raw observation to KV, push to real-time stream
|
v
mem::compress LLM extracts structured data (type, facts, narrative, concepts, files)
| Runs async -- doesn't block the coding session
v
mem::summarize End-of-session: LLM generates session summary from all observations
```
### What Gets Captured
| Hook | Data |
|------|------|
| `SessionStart` | Project path, session ID, working directory |
| `UserPromptSubmit` | User prompts (privacy-filtered) |
| `PostToolUse` | Tool name, input, output (Read, Write, Edit, Bash, etc.) |
| `Stop` | Triggers session summarization |
| `SessionEnd` | Marks session complete |
### Privacy
All observations pass through `mem::privacy` before storage:
- `<private>...</private>` blocks are stripped
- API keys, tokens, and secrets are redacted (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`, etc.)
## Context Injection
On `SessionStart`, agentmemory builds a context block from previous sessions:
```
mem::context
|
+-- Load recent sessions for this project (up to 10)
+-- For each: prefer session summary, fall back to high-importance observations
+-- Apply token budget (default: 2000 tokens)
+-- Return formatted <agentmemory-context> block
```
The hook writes the context to stdout, and Claude Code injects it into the conversation.
## LLM Providers
agentmemory uses LLM calls for two operations: compressing observations and generating session summaries. Four providers are supported:
| Provider | Config | Cost |
|----------|--------|------|
| **Claude subscription** (default) | No config needed | Included in Max/Pro |
| **Anthropic API** | `ANTHROPIC_API_KEY` | Per-token |
| **Gemini** | `GEMINI_API_KEY` | Per-token |
| **OpenRouter** | `OPENROUTER_API_KEY` | Per-token |
Provider is auto-detected. No API key configured = subscription mode via `@anthropic-ai/claude-agent-sdk`.
### Configure
Create `~/.agentmemory/.env`:
```env
# Pick one (or leave empty for subscription mode)
ANTHROPIC_API_KEY=sk-ant-...
# GEMINI_API_KEY=...
# OPENROUTER_API_KEY=...
# Optional: override model
# ANTHROPIC_MODEL=claude-sonnet-4-20250514
# GEMINI_MODEL=gemini-2.0-flash
# OPENROUTER_MODEL=anthropic/claude-sonnet-4-20250514
# Optional: engine connection (defaults shown)
# III_ENGINE_URL=ws://localhost:49134
# III_REST_PORT=3111
# III_STREAMS_PORT=3112
# Optional: memory settings
# TOKEN_BUDGET=2000
# MAX_OBS_PER_SESSION=500
```
## API Endpoints
All endpoints are served via iii-engine's REST API on port `3111`.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/agentmemory/health` | Health check |
| `POST` | `/agentmemory/session/start` | Start session, return context from previous sessions |
| `POST` | `/agentmemory/session/end` | Mark session complete |
| `POST` | `/agentmemory/observe` | Capture a tool-use observation |
| `POST` | `/agentmemory/context` | Generate context for a project |
| `POST` | `/agentmemory/search` | Search observations by keyword |
| `POST` | `/agentmemory/summarize` | Generate session summary |
| `GET` | `/agentmemory/sessions` | List all sessions |
| `GET` | `/agentmemory/observations?sessionId=X` | Get observations for a session |
| `GET` | `/agentmemory/viewer` | Real-time web viewer |
| `POST` | `/agentmemory/migrate` | Import from SQLite database |
### Examples
```bash
# Health check
curl http://localhost:3111/agentmemory/health
# Search observations
curl -X POST http://localhost:3111/agentmemory/search \
-H "Content-Type: application/json" \
-d '{"query": "authentication middleware", "limit": 10}'
# Get context for a project
curl -X POST http://localhost:3111/agentmemory/context \
-H "Content-Type: application/json" \
-d '{"sessionId": "current", "project": "/path/to/project"}'
# List all sessions
curl http://localhost:3111/agentmemory/sessions
```
## Real-Time Viewer
Open `http://localhost:3111/agentmemory/viewer` in your browser.
The viewer connects to iii-engine's WebSocket stream on port `3112` and shows observations as they flow in -- raw captures first, then structured data after LLM compression.
Dark theme. Timeline layout. Filter by session. Auto-scrolls.
## Claude Code Plugin
Install as a plugin for automatic hook registration:
```bash
# Build first
npm run build
# Option 1: Symlink the plugin directory
ln -s /path/to/agentmemory/plugin ~/.claude/plugins/agentmemory
# Option 2: Copy plugin manifest
cp -r plugin/ ~/.claude/plugins/agentmemory
```
The plugin registers all 5 hooks automatically via `hooks.json`.
## Migration
Import data from existing SQLite-based memory systems:
```bash
curl -X POST http://localhost:3111/agentmemory/migrate \
-H "Content-Type: application/json" \
-d '{"dbPath": "/path/to/memory.db"}'
```
Reads `sessions`, `observations`/`compressed_observations`, and `session_summaries` tables. Requires `better-sqlite3` as an optional dependency:
```bash
npm install better-sqlite3
```
## iii-engine Functions
agentmemory registers these iii Functions:
| Function ID | Description |
|-------------|-------------|
| `mem::observe` | Capture and store a tool-use observation |
| `mem::compress` | LLM compression of raw observation into structured data |
| `mem::privacy` | Strip private data, secrets, and API keys |
| `mem::search` | Keyword search across compressed observations |
| `mem::context` | Generate token-budgeted context from past sessions |
| `mem::summarize` | LLM-generated end-of-session summary |
| `mem::migrate` | Import data from SQLite databases |
| `api::health` | Health check endpoint |
| `api::session::start` | Session start + context retrieval |
| `api::session::end` | Mark session complete |
| `api::observe` | HTTP -> `mem::observe` |
| `api::context` | HTTP -> `mem::context` |
| `api::search` | HTTP -> `mem::search` |
| `api::summarize` | HTTP -> `mem::summarize` |
| `api::sessions` | List all sessions |
| `api::observations` | Get session observations |
| `api::viewer` | Serve web viewer |
| `api::migrate` | HTTP -> `mem::migrate` |
Event triggers (queue type) handle async lifecycle events: `session.started`, `observation`, `session.stopped`, `session.ended`.
## KV State Schema
| Scope | Key | Data |
|-------|-----|------|
| `mem:sessions` | `{session_id}` | Session metadata, project, timestamps |
| `mem:obs:{session_id}` | `{obs_id}` | Compressed observations |
| `mem:summaries` | `{session_id}` | End-of-session summaries |
| `mem:memories` | `{memory_id}` | Long-term memories |
| `mem:config` | `{key}` | Plugin settings |
## Project Structure
```
agentmemory/
├── src/
│ ├── index.ts # Worker entry: init SDK, register all
│ ├── types.ts # TypeScript interfaces
│ ├── config.ts # Env vars, provider auto-detection
│ ├── hooks/ # 5 Claude Code hook scripts
│ │ ├── session-start.ts # Start session, inject context (stdout)
│ │ ├── prompt-submit.ts # Capture user prompts
│ │ ├── post-tool-use.ts # Capture tool usage
│ │ ├── stop.ts # Trigger session summarization
│ │ └── session-end.ts # Mark session complete
│ ├── providers/ # LLM provider abstraction
│ │ ├── index.ts # Factory + auto-detection
│ │ ├── agent-sdk.ts # Claude subscription (Agent SDK)
│ │ ├── anthropic.ts # Direct Anthropic API
│ │ └── openrouter.ts # OpenRouter + Gemini
│ ├── functions/ # iii Functions (core logic)
│ │ ├── observe.ts # Capture + store + stream
│ │ ├── compress.ts # LLM compression
│ │ ├── privacy.ts # Secret/tag stripping
│ │ ├── search.ts # Keyword search
│ │ ├── context.ts # Context generation
│ │ ├── summarize.ts # Session summary
│ │ └── migrate.ts # SQLite import
│ ├── triggers/ # Trigger registrations
│ │ ├── api.ts # 11 HTTP endpoints
│ │ └── events.ts # 4 queue event handlers
│ ├── state/ # State helpers
│ │ ├── kv.ts # Typed KV wrapper
│ │ ├── schema.ts # Scope constants + ID generators
│ │ └── search-index.ts # In-memory keyword index
│ ├── prompts/ # LLM prompts
│ │ ├── compression.ts # Observation compression prompt
│ │ └── summary.ts # Session summary prompt
│ └── viewer/
│ └── index.html # Single-file dark-theme viewer
├── plugin/ # Claude Code plugin
│ ├── .claude-plugin/
│ │ └── plugin.json
│ └── hooks/
│ └── hooks.json
├── docker-compose.yml # iii-engine container
├── package.json
├── tsconfig.json
├── tsdown.config.ts
└── LICENSE # Apache-2.0
```
## Prerequisites
- **Node.js** >= 18
- **Docker** (for iii-engine)
- **Claude Code** (for hook integration)
## Development
```bash
# Dev mode with hot reload
npm run dev
# Build
npm run build
# Run tests
npm test
```
## License
[Apache-2.0](LICENSE)
+14
View File
@@ -0,0 +1,14 @@
services:
iii-engine:
image: iiidev/iii:latest
ports:
- "49134:49134"
- "3111:3111"
- "3112:3112"
- "9464:9464"
volumes:
- iii-data:/data
restart: unless-stopped
volumes:
iii-data:
+3724
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "agentmemory",
"version": "0.1.0",
"description": "Persistent memory for AI coding agents, powered by iii-engine's three primitives",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"agentmemory": "dist/index.js"
},
"scripts": {
"build": "tsdown",
"dev": "tsx src/index.ts",
"start": "node dist/index.js",
"migrate": "node dist/functions/migrate.js",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"ai",
"agent",
"memory",
"persistent",
"iii-engine",
"claude-code",
"coding-agent",
"context",
"observation"
],
"author": "Rohit Ghumare <ghumare64@gmail.com>",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.56",
"@anthropic-ai/sdk": "^0.39.0",
"dotenv": "^16.4.7",
"iii-sdk": "^0.3.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsdown": "^0.20.3",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "agentmemory",
"version": "0.1.0",
"description": "Persistent memory for AI coding agents powered by iii-engine",
"author": "Rohit Ghumare",
"license": "Apache-2.0",
"homepage": "https://github.com/rohitg00/agentmemory",
"hooks": "../hooks/hooks.json"
}
+34
View File
@@ -0,0 +1,34 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": "node __PLUGIN_DIR__/../dist/hooks/session-start.js"
}
],
"UserPromptSubmit": [
{
"type": "command",
"command": "node __PLUGIN_DIR__/../dist/hooks/prompt-submit.js"
}
],
"PostToolUse": [
{
"type": "command",
"command": "node __PLUGIN_DIR__/../dist/hooks/post-tool-use.js"
}
],
"Stop": [
{
"type": "command",
"command": "node __PLUGIN_DIR__/../dist/hooks/stop.js"
}
],
"SessionEnd": [
{
"type": "command",
"command": "node __PLUGIN_DIR__/../dist/hooks/session-end.js"
}
]
}
}
+86
View File
@@ -0,0 +1,86 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { AgentMemoryConfig, ProviderConfig } from "./types.js";
const DATA_DIR = join(homedir(), ".agentmemory");
const ENV_FILE = join(DATA_DIR, ".env");
function loadEnvFile(): Record<string, string> {
if (!existsSync(ENV_FILE)) return {};
const content = readFileSync(ENV_FILE, "utf-8");
const vars: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
vars[key] = val;
}
return vars;
}
function detectProvider(env: Record<string, string>): ProviderConfig {
const maxTokens = parseInt(env["MAX_TOKENS"] || "4096", 10);
if (env["ANTHROPIC_API_KEY"]) {
return {
provider: "anthropic",
model: env["ANTHROPIC_MODEL"] || "claude-sonnet-4-20250514",
maxTokens,
};
}
if (env["GEMINI_API_KEY"]) {
return {
provider: "gemini",
model: env["GEMINI_MODEL"] || "gemini-2.0-flash",
maxTokens,
};
}
if (env["OPENROUTER_API_KEY"]) {
return {
provider: "openrouter",
model: env["OPENROUTER_MODEL"] || "anthropic/claude-sonnet-4-20250514",
maxTokens,
};
}
return {
provider: "agent-sdk",
model: "claude-sonnet-4-20250514",
maxTokens: 4096,
};
}
export function loadConfig(): AgentMemoryConfig {
const fileEnv = loadEnvFile();
const env = { ...fileEnv, ...process.env } as Record<string, string>;
const provider = detectProvider(env);
return {
engineUrl: env["III_ENGINE_URL"] || "ws://localhost:49134",
restPort: parseInt(env["III_REST_PORT"] || "3111", 10),
streamsPort: parseInt(env["III_STREAMS_PORT"] || "3112", 10),
provider,
tokenBudget: parseInt(env["TOKEN_BUDGET"] || "2000", 10),
maxObservationsPerSession: parseInt(
env["MAX_OBS_PER_SESSION"] || "500",
10,
),
compressionModel: provider.model,
dataDir: DATA_DIR,
};
}
export function getEnvVar(key: string): string | undefined {
const fileEnv = loadEnvFile();
return process.env[key] || fileEnv[key];
}
+111
View File
@@ -0,0 +1,111 @@
import type { ISdk } from "iii-sdk";
import { getContext } from "iii-sdk";
import type {
RawObservation,
CompressedObservation,
ObservationType,
MemoryProvider,
} from "../types.js";
import { KV, STREAM } from "../state/schema.js";
import { StateKV } from "../state/kv.js";
import {
COMPRESSION_SYSTEM,
buildCompressionPrompt,
} from "../prompts/compression.js";
import { getXmlTag, getXmlChildren } from "../prompts/xml.js";
import { getSearchIndex } from "./search.js";
function parseCompressionXml(
xml: string,
): Omit<CompressedObservation, "id" | "sessionId" | "timestamp"> | null {
const type = getXmlTag(xml, "type");
const title = getXmlTag(xml, "title");
if (!type || !title) return null;
return {
type: type as ObservationType,
title,
subtitle: getXmlTag(xml, "subtitle") || undefined,
facts: getXmlChildren(xml, "facts", "fact"),
narrative: getXmlTag(xml, "narrative"),
concepts: getXmlChildren(xml, "concepts", "concept"),
files: getXmlChildren(xml, "files", "file"),
importance: parseInt(getXmlTag(xml, "importance") || "5", 10),
};
}
export function registerCompressFunction(
sdk: ISdk,
kv: StateKV,
provider: MemoryProvider,
): void {
sdk.registerFunction(
{
id: "mem::compress",
description: "Compress a raw observation using LLM",
},
async (data: {
observationId: string;
sessionId: string;
raw: RawObservation;
}) => {
const ctx = getContext();
const prompt = buildCompressionPrompt({
hookType: data.raw.hookType,
toolName: data.raw.toolName,
toolInput: data.raw.toolInput,
toolOutput: data.raw.toolOutput,
userPrompt: data.raw.userPrompt,
timestamp: data.raw.timestamp,
});
try {
const response = await provider.compress(COMPRESSION_SYSTEM, prompt);
const parsed = parseCompressionXml(response);
if (!parsed) {
ctx.logger.warn("Failed to parse compression XML", {
obsId: data.observationId,
});
return { success: false, error: "parse_failed" };
}
const compressed: CompressedObservation = {
id: data.observationId,
sessionId: data.sessionId,
timestamp: data.raw.timestamp,
...parsed,
};
await kv.set(
KV.observations(data.sessionId),
data.observationId,
compressed,
);
getSearchIndex().add(compressed);
await sdk.trigger("stream::set", {
stream_name: STREAM.name,
group_id: STREAM.group(data.sessionId),
item_id: data.observationId,
data: { type: "compressed", observation: compressed },
});
ctx.logger.info("Observation compressed", {
obsId: data.observationId,
type: compressed.type,
importance: compressed.importance,
});
return { success: true, compressed };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.logger.error("Compression failed", {
obsId: data.observationId,
error: msg,
});
return { success: false, error: msg };
}
},
);
}
+81
View File
@@ -0,0 +1,81 @@
import type { ISdk } from 'iii-sdk'
import { getContext } from 'iii-sdk'
import type { Session, CompressedObservation, SessionSummary, ContextBlock } from '../types.js'
import { KV } from '../state/schema.js'
import { StateKV } from '../state/kv.js'
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4)
}
export function registerContextFunction(sdk: ISdk, kv: StateKV, tokenBudget: number): void {
sdk.registerFunction(
{ id: 'mem::context', description: 'Generate context for session injection' },
async (data: { sessionId: string; project: string; budget?: number }) => {
const ctx = getContext()
const budget = data.budget || tokenBudget
const blocks: ContextBlock[] = []
const allSessions = await kv.list<Session>(KV.sessions)
const sessions = allSessions
.filter((s) => s.project === data.project && s.id !== data.sessionId)
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
.slice(0, 10)
for (const session of sessions) {
const summary = await kv.get<SessionSummary>(KV.summaries, session.id)
if (summary) {
const content = `## ${summary.title}\n${summary.narrative}\nDecisions: ${summary.keyDecisions.join('; ')}\nFiles: ${summary.filesModified.join(', ')}`
blocks.push({
type: 'summary',
content,
tokens: estimateTokens(content),
recency: new Date(summary.createdAt).getTime(),
})
continue
}
const observations = await kv.list<CompressedObservation>(KV.observations(session.id))
const important = observations.filter((o) => o.title && o.importance >= 5)
if (important.length > 0) {
const content = important
.sort((a, b) => b.importance - a.importance)
.slice(0, 5)
.map((o) => `- [${o.type}] ${o.title}: ${o.narrative}`)
.join('\n')
blocks.push({
type: 'observation',
content: `## Session ${session.id.slice(0, 8)} (${session.startedAt})\n${content}`,
tokens: estimateTokens(content),
recency: new Date(session.startedAt).getTime(),
})
}
}
blocks.sort((a, b) => b.recency - a.recency)
let usedTokens = 0
const selected: string[] = []
const header = `<agentmemory-context project="${data.project}">`
const footer = `</agentmemory-context>`
usedTokens += estimateTokens(header) + estimateTokens(footer)
for (const block of blocks) {
if (usedTokens + block.tokens > budget) break
selected.push(block.content)
usedTokens += block.tokens
}
if (selected.length === 0) {
ctx.logger.info('No context available', { project: data.project })
return { context: '', blocks: 0, tokens: 0 }
}
const result = `${header}\n${selected.join('\n\n')}\n${footer}`
ctx.logger.info('Context generated', { blocks: selected.length, tokens: usedTokens })
return { context: result, blocks: selected.length, tokens: usedTokens }
}
)
}
+168
View File
@@ -0,0 +1,168 @@
import type { ISdk } from "iii-sdk";
import { getContext } from "iii-sdk";
import { resolve } from "node:path";
import { homedir } from "node:os";
import { KV, generateId } from "../state/schema.js";
import { StateKV } from "../state/kv.js";
import type {
Session,
CompressedObservation,
SessionSummary,
} from "../types.js";
const ALLOWED_DIRS = [
resolve(homedir(), ".agentmemory"),
resolve(homedir(), ".claude"),
];
function isAllowedPath(dbPath: string): boolean {
const resolved = resolve(dbPath);
return ALLOWED_DIRS.some((dir) => resolved.startsWith(dir + "/"));
}
export function registerMigrateFunction(sdk: ISdk, kv: StateKV): void {
sdk.registerFunction(
{
id: "mem::migrate",
description: "Import data from SQLite database",
},
async (data: { dbPath: string }) => {
const ctx = getContext();
ctx.logger.info("Migration started", { dbPath: data.dbPath });
if (!isAllowedPath(data.dbPath)) {
return {
success: false,
error: `Path not allowed. Must be under: ${ALLOWED_DIRS.join(", ")}`,
};
}
let Database: any;
try {
// @ts-expect-error optional dependency
Database = (await import("better-sqlite3")).default;
} catch {
return {
success: false,
error:
"better-sqlite3 not installed. Run: npm install better-sqlite3",
};
}
const fs = await import("node:fs");
if (!fs.existsSync(data.dbPath)) {
return { success: false, error: `Database not found: ${data.dbPath}` };
}
try {
const db = Database(data.dbPath, { readonly: true });
let sessionCount = 0;
let obsCount = 0;
let summaryCount = 0;
const sessions = db
.prepare("SELECT * FROM sessions ORDER BY created_at DESC")
.all() as any[];
for (const row of sessions) {
const session: Session = {
id: row.session_id || row.id,
project: row.project_path || row.project || "unknown",
cwd: row.cwd || row.project_path || "",
startedAt:
row.created_at || row.started_at || new Date().toISOString(),
endedAt: row.ended_at || row.updated_at,
status: "completed",
observationCount: 0,
};
await kv.set(KV.sessions, session.id, session);
sessionCount++;
}
let observations: any[] = [];
try {
observations = db
.prepare("SELECT * FROM observations ORDER BY created_at ASC")
.all() as any[];
} catch {
try {
observations = db
.prepare(
"SELECT * FROM compressed_observations ORDER BY created_at ASC",
)
.all() as any[];
} catch {
ctx.logger.warn("No observation tables found");
}
}
for (const row of observations) {
const sessionId = row.session_id || "migrated";
const obs: CompressedObservation = {
id: row.id || generateId("mig"),
sessionId,
timestamp: row.created_at || new Date().toISOString(),
type: row.type || "other",
title: row.title || row.summary || "Migrated observation",
subtitle: row.subtitle,
facts: safeJsonParse(row.facts, []),
narrative: row.narrative || row.content || "",
concepts: safeJsonParse(row.concepts, []),
files: safeJsonParse(row.files, []),
importance: row.importance || 5,
};
await kv.set(KV.observations(sessionId), obs.id, obs);
obsCount++;
}
let summaries: any[] = [];
try {
summaries = db
.prepare("SELECT * FROM session_summaries")
.all() as any[];
} catch {
ctx.logger.warn("No summaries table found");
}
for (const row of summaries) {
const summary: SessionSummary = {
sessionId: row.session_id,
project: row.project || "unknown",
createdAt: row.created_at || new Date().toISOString(),
title: row.title || "Migrated session",
narrative: row.narrative || row.summary || "",
keyDecisions: safeJsonParse(row.key_decisions, []),
filesModified: safeJsonParse(row.files_modified, []),
concepts: safeJsonParse(row.concepts, []),
observationCount: row.observation_count || 0,
};
await kv.set(KV.summaries, row.session_id, summary);
summaryCount++;
}
db.close();
ctx.logger.info("Migration complete", {
sessionCount,
obsCount,
summaryCount,
});
return { success: true, sessionCount, obsCount, summaryCount };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ctx.logger.error("Migration failed", { error: msg });
return { success: false, error: msg };
}
},
);
}
function safeJsonParse<T>(value: unknown, fallback: T): T {
if (Array.isArray(value)) return value as T;
if (typeof value === "string") {
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
return fallback;
}
+73
View File
@@ -0,0 +1,73 @@
import type { ISdk } from "iii-sdk";
import { getContext } from "iii-sdk";
import type { RawObservation, HookPayload, Session } from "../types.js";
import { KV, STREAM, generateId } from "../state/schema.js";
import { StateKV } from "../state/kv.js";
import { stripPrivateData } from "./privacy.js";
export function registerObserveFunction(sdk: ISdk, kv: StateKV): void {
sdk.registerFunction(
{
id: "mem::observe",
description: "Capture and store a tool-use observation",
},
async (payload: HookPayload) => {
const ctx = getContext();
const obsId = generateId("obs");
const sanitizedData = stripPrivateData(JSON.stringify(payload.data));
const raw: RawObservation = {
id: obsId,
sessionId: payload.sessionId,
timestamp: payload.timestamp,
hookType: payload.hookType,
raw: JSON.parse(sanitizedData),
};
const d =
typeof payload.data === "object" && payload.data !== null
? (payload.data as Record<string, unknown>)
: null;
if (payload.hookType === "post_tool_use" && d) {
raw.toolName = d["tool_name"] as string | undefined;
raw.toolInput = d["tool_input"];
raw.toolOutput = d["tool_output"];
}
if (payload.hookType === "prompt_submit" && d) {
raw.userPrompt = d["prompt"] as string | undefined;
}
await kv.set(KV.observations(payload.sessionId), obsId, raw);
await sdk.trigger("stream::set", {
stream_name: STREAM.name,
group_id: STREAM.group(payload.sessionId),
item_id: obsId,
data: { type: "raw", observation: raw },
});
const session = await kv.get<Session>(KV.sessions, payload.sessionId);
if (session) {
await kv.set(KV.sessions, payload.sessionId, {
...session,
observationCount: (session.observationCount || 0) + 1,
});
}
sdk.triggerVoid("mem::compress", {
observationId: obsId,
sessionId: payload.sessionId,
raw,
});
ctx.logger.info("Observation captured", {
obsId,
sessionId: payload.sessionId,
hook: payload.hookType,
});
return { observationId: obsId };
},
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { ISdk } from 'iii-sdk'
const PRIVATE_TAG_RE = /<private>[\s\S]*?<\/private>/gi
const SECRET_PATTERN_SOURCES = [
/(?:api[_-]?key|secret|token|password|credential|auth)[\s]*[=:]\s*["']?[A-Za-z0-9_\-/.+]{20,}["']?/gi,
/(?:sk|pk|rk|ak)-[A-Za-z0-9]{20,}/g,
/ghp_[A-Za-z0-9]{36}/g,
/xoxb-[A-Za-z0-9\-]+/g,
/AKIA[0-9A-Z]{16}/g,
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
]
export function stripPrivateData(input: string): string {
let result = input.replace(PRIVATE_TAG_RE, '[REDACTED]')
for (const source of SECRET_PATTERN_SOURCES) {
const pattern = new RegExp(source.source, source.flags)
result = result.replace(pattern, '[REDACTED_SECRET]')
}
return result
}
export function registerPrivacyFunction(sdk: ISdk): void {
sdk.registerFunction(
{ id: 'mem::privacy', description: 'Strip private tags and secrets from input' },
async (data: { input: string }) => {
return { output: stripPrivateData(data.input) }
}
)
}
+61
View File
@@ -0,0 +1,61 @@
import type { ISdk } from 'iii-sdk'
import { getContext } from 'iii-sdk'
import type { CompressedObservation, SearchResult, Session } from '../types.js'
import { KV } from '../state/schema.js'
import { StateKV } from '../state/kv.js'
import { SearchIndex } from '../state/search-index.js'
let index: SearchIndex | null = null
export function getSearchIndex(): SearchIndex {
if (!index) index = new SearchIndex()
return index
}
export async function rebuildIndex(kv: StateKV): Promise<number> {
const idx = getSearchIndex()
idx.clear()
const sessions = await kv.list<Session>(KV.sessions)
if (!sessions.length) return 0
let count = 0
for (const session of sessions) {
const observations = await kv.list<CompressedObservation>(KV.observations(session.id))
for (const obs of observations) {
if (obs.title && obs.narrative) {
idx.add(obs)
count++
}
}
}
return count
}
export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
sdk.registerFunction(
{ id: 'mem::search', description: 'Search observations by keyword' },
async (data: { query: string; limit?: number }) => {
const ctx = getContext()
const idx = getSearchIndex()
if (idx.size === 0) {
const count = await rebuildIndex(kv)
ctx.logger.info('Search index rebuilt', { entries: count })
}
const results = idx.search(data.query, data.limit || 20)
const enriched: SearchResult[] = []
for (const r of results) {
const obs = await kv.get<CompressedObservation>(KV.observations(r.sessionId), r.obsId)
if (obs) {
enriched.push({ observation: obs, score: r.score, sessionId: r.sessionId })
}
}
ctx.logger.info('Search completed', { query: data.query, results: enriched.length })
return { results: enriched }
}
)
}
+72
View File
@@ -0,0 +1,72 @@
import type { ISdk } from 'iii-sdk'
import { getContext } from 'iii-sdk'
import type { CompressedObservation, SessionSummary, MemoryProvider, Session } from '../types.js'
import { KV } from '../state/schema.js'
import { StateKV } from '../state/kv.js'
import { SUMMARY_SYSTEM, buildSummaryPrompt } from '../prompts/summary.js'
import { getXmlTag, getXmlChildren } from '../prompts/xml.js'
function parseSummaryXml(xml: string, sessionId: string, project: string, obsCount: number): SessionSummary | null {
const title = getXmlTag(xml, 'title')
if (!title) return null
return {
sessionId,
project,
createdAt: new Date().toISOString(),
title,
narrative: getXmlTag(xml, 'narrative'),
keyDecisions: getXmlChildren(xml, 'decisions', 'decision'),
filesModified: getXmlChildren(xml, 'files', 'file'),
concepts: getXmlChildren(xml, 'concepts', 'concept'),
observationCount: obsCount,
}
}
export function registerSummarizeFunction(sdk: ISdk, kv: StateKV, provider: MemoryProvider): void {
sdk.registerFunction(
{ id: 'mem::summarize', description: 'Generate end-of-session summary' },
async (data: { sessionId: string }) => {
const ctx = getContext()
const session = await kv.get<Session>(KV.sessions, data.sessionId)
if (!session) {
ctx.logger.warn('Session not found for summarize', { sessionId: data.sessionId })
return { success: false, error: 'session_not_found' }
}
const observations = await kv.list<CompressedObservation>(KV.observations(data.sessionId))
const compressed = observations.filter((o) => o.title)
if (compressed.length === 0) {
ctx.logger.info('No observations to summarize', { sessionId: data.sessionId })
return { success: false, error: 'no_observations' }
}
try {
const prompt = buildSummaryPrompt(compressed)
const response = await provider.summarize(SUMMARY_SYSTEM, prompt)
const summary = parseSummaryXml(response, data.sessionId, session.project, compressed.length)
if (!summary) {
ctx.logger.warn('Failed to parse summary XML', { sessionId: data.sessionId })
return { success: false, error: 'parse_failed' }
}
await kv.set(KV.summaries, data.sessionId, summary)
ctx.logger.info('Session summarized', {
sessionId: data.sessionId,
title: summary.title,
decisions: summary.keyDecisions.length,
})
return { success: true, summary }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
ctx.logger.error('Summarize failed', { sessionId: data.sessionId, error: msg })
return { success: false, error: msg }
}
}
)
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
const REST_URL = process.env['AGENTMEMORY_URL'] || 'http://localhost:3111'
async function main() {
let input = ''
for await (const chunk of process.stdin) {
input += chunk
}
let data: Record<string, unknown>
try {
data = JSON.parse(input)
} catch {
return
}
const sessionId = (data.session_id as string) || 'unknown'
try {
await fetch(`${REST_URL}/agentmemory/observe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hookType: 'post_tool_use',
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: {
tool_name: data.tool_name,
tool_input: data.tool_input,
tool_output: truncate(data.tool_output, 8000),
},
}),
signal: AbortSignal.timeout(3000),
})
} catch {
// fire and forget
}
}
function truncate(value: unknown, max: number): unknown {
if (typeof value === 'string' && value.length > max) {
return value.slice(0, max) + '\n[...truncated]'
}
if (typeof value === 'object' && value !== null) {
const str = JSON.stringify(value)
if (str.length > max) return JSON.parse(str.slice(0, max - 1) + '}')
return value
}
return value
}
main()
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
const REST_URL = process.env['AGENTMEMORY_URL'] || 'http://localhost:3111'
async function main() {
let input = ''
for await (const chunk of process.stdin) {
input += chunk
}
let data: Record<string, unknown>
try {
data = JSON.parse(input)
} catch {
return
}
const sessionId = (data.session_id as string) || 'unknown'
try {
await fetch(`${REST_URL}/agentmemory/observe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hookType: 'prompt_submit',
sessionId,
project: data.cwd || process.cwd(),
cwd: data.cwd || process.cwd(),
timestamp: new Date().toISOString(),
data: { prompt: data.prompt },
}),
signal: AbortSignal.timeout(3000),
})
} catch {
// fire and forget
}
}
main()
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
const REST_URL = process.env['AGENTMEMORY_URL'] || 'http://localhost:3111'
async function main() {
let input = ''
for await (const chunk of process.stdin) {
input += chunk
}
let data: Record<string, unknown>
try {
data = JSON.parse(input)
} catch {
return
}
const sessionId = (data.session_id as string) || 'unknown'
try {
await fetch(`${REST_URL}/agentmemory/session/end`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
signal: AbortSignal.timeout(5000),
})
} catch {
// best-effort
}
}
main()
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
const REST_URL = process.env['AGENTMEMORY_URL'] || 'http://localhost:3111'
async function main() {
let input = ''
for await (const chunk of process.stdin) {
input += chunk
}
let data: Record<string, unknown>
try {
data = JSON.parse(input)
} catch {
return
}
const sessionId = (data.session_id as string) || `ses_${Date.now().toString(36)}`
const project = (data.cwd as string) || process.cwd()
try {
const res = await fetch(`${REST_URL}/agentmemory/session/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, project, cwd: project }),
signal: AbortSignal.timeout(5000),
})
if (res.ok) {
const result = await res.json() as { context?: string }
if (result.context) {
process.stdout.write(result.context)
}
}
} catch {
// silently fail -- don't block Claude Code startup
}
}
main()
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
const REST_URL = process.env['AGENTMEMORY_URL'] || 'http://localhost:3111'
async function main() {
let input = ''
for await (const chunk of process.stdin) {
input += chunk
}
let data: Record<string, unknown>
try {
data = JSON.parse(input)
} catch {
return
}
const sessionId = (data.session_id as string) || 'unknown'
try {
await fetch(`${REST_URL}/agentmemory/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId }),
signal: AbortSignal.timeout(30000),
})
} catch {
// summarize is best-effort
}
}
main()
+74
View File
@@ -0,0 +1,74 @@
import { init } from 'iii-sdk'
import { loadConfig } from './config.js'
import { createProvider } from './providers/index.js'
import { StateKV } from './state/kv.js'
import { registerPrivacyFunction } from './functions/privacy.js'
import { registerObserveFunction } from './functions/observe.js'
import { registerCompressFunction } from './functions/compress.js'
import { registerSearchFunction, rebuildIndex } from './functions/search.js'
import { registerContextFunction } from './functions/context.js'
import { registerSummarizeFunction } from './functions/summarize.js'
import { registerMigrateFunction } from './functions/migrate.js'
import { registerApiTriggers } from './triggers/api.js'
import { registerEventTriggers } from './triggers/events.js'
async function main() {
const config = loadConfig()
const provider = createProvider(config.provider)
console.log(`[agentmemory] Starting worker...`)
console.log(`[agentmemory] Engine: ${config.engineUrl}`)
console.log(`[agentmemory] Provider: ${config.provider.provider} (${config.provider.model})`)
console.log(`[agentmemory] REST API: http://localhost:${config.restPort}/agentmemory/*`)
console.log(`[agentmemory] Streams: ws://localhost:${config.streamsPort}`)
const sdk = init(config.engineUrl, {
workerName: 'agentmemory',
})
const kv = new StateKV(sdk)
registerPrivacyFunction(sdk)
registerObserveFunction(sdk, kv)
registerCompressFunction(sdk, kv, provider)
registerSearchFunction(sdk, kv)
registerContextFunction(sdk, kv, config.tokenBudget)
registerSummarizeFunction(sdk, kv, provider)
registerMigrateFunction(sdk, kv)
registerApiTriggers(sdk, kv)
registerEventTriggers(sdk)
const indexCount = await rebuildIndex(kv).catch(() => 0)
if (indexCount > 0) {
console.log(`[agentmemory] Search index: ${indexCount} observations`)
}
console.log(`[agentmemory] Ready. Endpoints:`)
console.log(` POST /agentmemory/session/start - Start session + get context`)
console.log(` POST /agentmemory/observe - Capture observation`)
console.log(` POST /agentmemory/context - Generate context`)
console.log(` POST /agentmemory/search - Search observations`)
console.log(` POST /agentmemory/summarize - Summarize session`)
console.log(` GET /agentmemory/sessions - List sessions`)
console.log(` GET /agentmemory/observations - Get session observations`)
console.log(` GET /agentmemory/health - Health check`)
console.log(` GET /agentmemory/viewer - Web viewer`)
console.log(` POST /agentmemory/migrate - Import from SQLite`)
process.on('SIGINT', async () => {
console.log(`\n[agentmemory] Shutting down...`)
await sdk.shutdown()
process.exit(0)
})
process.on('SIGTERM', async () => {
await sdk.shutdown()
process.exit(0)
})
}
main().catch((err) => {
console.error(`[agentmemory] Fatal:`, err)
process.exit(1)
})
+62
View File
@@ -0,0 +1,62 @@
export const COMPRESSION_SYSTEM = `You are a memory compression engine for an AI coding agent. Your job is to extract the essential information from a tool usage observation and compress it into structured data.
Output EXACTLY this XML format with no additional text:
<observation>
<type>one of: file_read, file_write, file_edit, command_run, search, web_fetch, conversation, error, decision, discovery, other</type>
<title>Short descriptive title (max 80 chars)</title>
<subtitle>One-line context (optional)</subtitle>
<facts>
<fact>Specific factual detail 1</fact>
<fact>Specific factual detail 2</fact>
</facts>
<narrative>2-3 sentence summary of what happened and why it matters</narrative>
<concepts>
<concept>technical concept or pattern</concept>
</concepts>
<files>
<file>path/to/file</file>
</files>
<importance>1-10 scale, 10 being critical architectural decision</importance>
</observation>
Rules:
- Be concise but preserve ALL technically relevant details
- File paths must be exact
- Importance: 1-3 for routine reads, 4-6 for edits/commands, 7-9 for architectural decisions, 10 for breaking changes
- Concepts should be reusable search terms (e.g., "React hooks", "SQL migration", "auth middleware")
- Strip any secrets, tokens, or credentials from the output`
export function buildCompressionPrompt(observation: {
hookType: string
toolName?: string
toolInput?: unknown
toolOutput?: unknown
userPrompt?: string
timestamp: string
}): string {
const parts = [`Timestamp: ${observation.timestamp}`, `Hook: ${observation.hookType}`]
if (observation.toolName) parts.push(`Tool: ${observation.toolName}`)
if (observation.toolInput) {
const input = typeof observation.toolInput === 'string'
? observation.toolInput
: JSON.stringify(observation.toolInput, null, 2)
parts.push(`Input:\n${truncate(input, 4000)}`)
}
if (observation.toolOutput) {
const output = typeof observation.toolOutput === 'string'
? observation.toolOutput
: JSON.stringify(observation.toolOutput, null, 2)
parts.push(`Output:\n${truncate(output, 4000)}`)
}
if (observation.userPrompt) {
parts.push(`User prompt:\n${truncate(observation.userPrompt, 2000)}`)
}
return parts.join('\n\n')
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max) + '\n[...truncated]' : s
}
+38
View File
@@ -0,0 +1,38 @@
export const SUMMARY_SYSTEM = `You are a session summarizer for an AI coding agent's memory system. Given all compressed observations from a coding session, produce a concise session summary.
Output EXACTLY this XML format with no additional text:
<summary>
<title>Short session title (max 100 chars)</title>
<narrative>3-5 sentence narrative of what was accomplished</narrative>
<decisions>
<decision>Key technical decision made</decision>
</decisions>
<files>
<file>path/to/modified/file</file>
</files>
<concepts>
<concept>key concept from session</concept>
</concepts>
</summary>
Rules:
- Focus on outcomes, not individual tool calls
- Highlight decisions and their rationale
- List all files that were created or modified
- Concepts should be searchable terms for future context retrieval`
export function buildSummaryPrompt(observations: Array<{
type: string
title: string
facts: string[]
narrative: string
files: string[]
concepts: string[]
}>): string {
const lines = observations.map((obs, i) => {
const facts = obs.facts.map((f) => ` - ${f}`).join('\n')
return `[${i + 1}] ${obs.type}: ${obs.title}\n${obs.narrative}\nFacts:\n${facts}\nFiles: ${obs.files.join(', ')}`
})
return `Session observations (${observations.length} total):\n\n${lines.join('\n\n---\n\n')}`
}
+16
View File
@@ -0,0 +1,16 @@
export function getXmlTag(xml: string, tag: string): string {
const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))
return match ? match[1].trim() : ''
}
export function getXmlChildren(xml: string, parentTag: string, childTag: string): string[] {
const parentMatch = xml.match(new RegExp(`<${parentTag}>([\\s\\S]*?)</${parentTag}>`))
if (!parentMatch) return []
const items: string[] = []
const re = new RegExp(`<${childTag}>([\\s\\S]*?)</${childTag}>`, 'g')
let m
while ((m = re.exec(parentMatch[1])) !== null) {
items.push(m[1].trim())
}
return items
}
+34
View File
@@ -0,0 +1,34 @@
import type { MemoryProvider } from '../types.js'
export class AgentSDKProvider implements MemoryProvider {
name = 'agent-sdk'
async compress(systemPrompt: string, userPrompt: string): Promise<string> {
return this.query(systemPrompt, userPrompt)
}
async summarize(systemPrompt: string, userPrompt: string): Promise<string> {
return this.query(systemPrompt, userPrompt)
}
private async query(systemPrompt: string, userPrompt: string): Promise<string> {
const { query } = await import('@anthropic-ai/claude-agent-sdk')
const messages = query({
prompt: userPrompt,
options: {
systemPrompt,
maxTurns: 1,
allowedTools: [],
},
})
let result = ''
for await (const msg of messages) {
if (msg.type === 'result') {
result = typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result)
}
}
return result
}
}
+35
View File
@@ -0,0 +1,35 @@
import Anthropic from '@anthropic-ai/sdk'
import type { MemoryProvider } from '../types.js'
export class AnthropicProvider implements MemoryProvider {
name = 'anthropic'
private client: Anthropic
private model: string
private maxTokens: number
constructor(apiKey: string, model: string, maxTokens: number) {
this.client = new Anthropic({ apiKey })
this.model = model
this.maxTokens = maxTokens
}
async compress(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt)
}
async summarize(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt)
}
private async call(systemPrompt: string, userPrompt: string): Promise<string> {
const response = await this.client.messages.create({
model: this.model,
max_tokens: this.maxTokens,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
})
const textBlock = response.content.find((b) => b.type === 'text')
return textBlock?.text ?? ''
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { MemoryProvider, ProviderConfig } from '../types.js'
import { AgentSDKProvider } from './agent-sdk.js'
import { AnthropicProvider } from './anthropic.js'
import { OpenRouterProvider } from './openrouter.js'
import { getEnvVar } from '../config.js'
export function createProvider(config: ProviderConfig): MemoryProvider {
switch (config.provider) {
case 'anthropic':
return new AnthropicProvider(getEnvVar('ANTHROPIC_API_KEY')!, config.model, config.maxTokens)
case 'gemini':
return new OpenRouterProvider(
getEnvVar('GEMINI_API_KEY')!,
config.model,
config.maxTokens,
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'
)
case 'openrouter':
return new OpenRouterProvider(
getEnvVar('OPENROUTER_API_KEY')!,
config.model,
config.maxTokens,
'https://openrouter.ai/api/v1/chat/completions'
)
case 'agent-sdk':
default:
return new AgentSDKProvider()
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { MemoryProvider } from "../types.js";
export class OpenRouterProvider implements MemoryProvider {
name: string;
private apiKey: string;
private model: string;
private maxTokens: number;
private baseUrl: string;
constructor(
apiKey: string,
model: string,
maxTokens: number,
baseUrl: string,
) {
this.apiKey = apiKey;
this.model = model;
this.maxTokens = maxTokens;
this.baseUrl = baseUrl;
this.name = baseUrl.includes("openrouter") ? "openrouter" : "gemini";
}
async compress(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt);
}
async summarize(systemPrompt: string, userPrompt: string): Promise<string> {
return this.call(systemPrompt, userPrompt);
}
private async call(
systemPrompt: string,
userPrompt: string,
): Promise<string> {
const response = await fetch(this.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.apiKey}`,
...(this.baseUrl.includes("openrouter")
? { "HTTP-Referer": "https://github.com/rohitg00/agentmemory" }
: {}),
},
body: JSON.stringify({
model: this.model,
max_tokens: this.maxTokens,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`${this.name} API error (${response.status}): ${text}`);
}
const data = (await response.json()) as Record<string, unknown>;
const choices = data.choices as
| Array<{ message: { content: string } }>
| undefined;
const content = choices?.[0]?.message?.content;
if (!content) {
throw new Error(
`${this.name} returned unexpected response: ${JSON.stringify(data).slice(0, 200)}`,
);
}
return content;
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { ISdk } from 'iii-sdk'
export class StateKV {
constructor(private sdk: ISdk) {}
async get<T = unknown>(scope: string, key: string): Promise<T | null> {
return this.sdk.trigger<{ scope: string; key: string }, T | null>(
'state::get',
{ scope, key },
)
}
async set<T = unknown>(scope: string, key: string, data: T): Promise<T> {
return this.sdk.trigger<{ scope: string; key: string; data: T }, T>(
'state::set',
{ scope, key, data },
)
}
async delete(scope: string, key: string): Promise<void> {
return this.sdk.trigger<{ scope: string; key: string }, void>(
'state::delete',
{ scope, key },
)
}
async list<T = unknown>(scope: string): Promise<T[]> {
return this.sdk.trigger<{ scope: string }, T[]>(
'state::list',
{ scope },
)
}
}
+18
View File
@@ -0,0 +1,18 @@
export const KV = {
sessions: "mem:sessions",
observations: (sessionId: string) => `mem:obs:${sessionId}`,
memories: "mem:memories",
summaries: "mem:summaries",
config: "mem:config",
} as const;
export const STREAM = {
name: "mem-live",
group: (sessionId: string) => sessionId,
} as const;
export function generateId(prefix: string): string {
const ts = Date.now().toString(36);
const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
return `${prefix}_${ts}_${rand}`;
}
+96
View File
@@ -0,0 +1,96 @@
import type { CompressedObservation } from "../types.js";
interface IndexEntry {
obsId: string;
sessionId: string;
terms: Set<string>;
}
export class SearchIndex {
private entries: Map<string, IndexEntry> = new Map();
private invertedIndex: Map<string, Set<string>> = new Map();
add(obs: CompressedObservation): void {
const terms = this.extractTerms(obs);
const entry: IndexEntry = {
obsId: obs.id,
sessionId: obs.sessionId,
terms,
};
this.entries.set(obs.id, entry);
for (const term of terms) {
if (!this.invertedIndex.has(term)) {
this.invertedIndex.set(term, new Set());
}
this.invertedIndex.get(term)!.add(obs.id);
}
}
search(
query: string,
limit = 20,
): Array<{ obsId: string; sessionId: string; score: number }> {
const queryTerms = this.tokenize(query.toLowerCase());
if (queryTerms.length === 0) return [];
const scores = new Map<string, number>();
for (const term of queryTerms) {
const matches = this.invertedIndex.get(term);
if (!matches) continue;
for (const obsId of matches) {
scores.set(obsId, (scores.get(obsId) || 0) + 1);
}
for (const [indexTerm, obsIds] of this.invertedIndex) {
if (indexTerm !== term && indexTerm.includes(term)) {
for (const obsId of obsIds) {
scores.set(obsId, (scores.get(obsId) || 0) + 0.5);
}
}
}
}
return Array.from(scores.entries())
.map(([obsId, score]) => {
const entry = this.entries.get(obsId)!;
return {
obsId,
sessionId: entry.sessionId,
score: score / queryTerms.length,
};
})
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
get size(): number {
return this.entries.size;
}
clear(): void {
this.entries.clear();
this.invertedIndex.clear();
}
private extractTerms(obs: CompressedObservation): Set<string> {
const parts = [
obs.title,
obs.subtitle || "",
obs.narrative,
...obs.facts,
...obs.concepts,
...obs.files,
obs.type,
];
return new Set(this.tokenize(parts.join(" ").toLowerCase()));
}
private tokenize(text: string): string[] {
return text
.replace(/[^\w\s/.\-_]/g, " ")
.split(/\s+/)
.filter((t) => t.length > 1);
}
}
+131
View File
@@ -0,0 +1,131 @@
import type { ISdk, ApiRequest } from 'iii-sdk'
import type { Session, CompressedObservation, HookPayload } from '../types.js'
import { KV } from '../state/schema.js'
import { StateKV } from '../state/kv.js'
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
type Response = { status_code: number; headers?: Record<string, string>; body: unknown }
export function registerApiTriggers(sdk: ISdk, kv: StateKV): void {
sdk.registerFunction(
{ id: 'api::health' },
async (): Promise<Response> => ({
status_code: 200,
body: { status: 'ok', service: 'agentmemory', version: '0.1.0' },
})
)
sdk.registerTrigger({ type: 'http', function_id: 'api::health', config: { api_path: '/agentmemory/health', http_method: 'GET' } })
sdk.registerFunction(
{ id: 'api::observe' },
async (req: ApiRequest<HookPayload>): Promise<Response> => {
const result = await sdk.trigger('mem::observe', req.body)
return { status_code: 201, body: result }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::observe', config: { api_path: '/agentmemory/observe', http_method: 'POST' } })
sdk.registerFunction(
{ id: 'api::context' },
async (req: ApiRequest<{ sessionId: string; project: string; budget?: number }>): Promise<Response> => {
const result = await sdk.trigger('mem::context', req.body)
return { status_code: 200, body: result }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::context', config: { api_path: '/agentmemory/context', http_method: 'POST' } })
sdk.registerFunction(
{ id: 'api::search' },
async (req: ApiRequest<{ query: string; limit?: number }>): Promise<Response> => {
const result = await sdk.trigger('mem::search', req.body)
return { status_code: 200, body: result }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::search', config: { api_path: '/agentmemory/search', http_method: 'POST' } })
sdk.registerFunction(
{ id: 'api::session::start' },
async (req: ApiRequest<{ sessionId: string; project: string; cwd: string }>): Promise<Response> => {
const { sessionId, project, cwd } = req.body
const session: Session = {
id: sessionId, project, cwd,
startedAt: new Date().toISOString(),
status: 'active',
observationCount: 0,
}
await kv.set(KV.sessions, sessionId, session)
const contextResult = await sdk.trigger<{ sessionId: string; project: string }, { context: string }>('mem::context', { sessionId, project })
return { status_code: 200, body: { session, context: contextResult.context } }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::session::start', config: { api_path: '/agentmemory/session/start', http_method: 'POST' } })
sdk.registerFunction(
{ id: 'api::session::end' },
async (req: ApiRequest<{ sessionId: string }>): Promise<Response> => {
const session = await kv.get<Session>(KV.sessions, req.body.sessionId)
if (session) {
await kv.set(KV.sessions, req.body.sessionId, {
...session, endedAt: new Date().toISOString(), status: 'completed' as const,
})
}
return { status_code: 200, body: { success: true } }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::session::end', config: { api_path: '/agentmemory/session/end', http_method: 'POST' } })
sdk.registerFunction(
{ id: 'api::summarize' },
async (req: ApiRequest<{ sessionId: string }>): Promise<Response> => {
const result = await sdk.trigger('mem::summarize', req.body)
return { status_code: 200, body: result }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::summarize', config: { api_path: '/agentmemory/summarize', http_method: 'POST' } })
sdk.registerFunction(
{ id: 'api::sessions' },
async (): Promise<Response> => {
const sessions = await kv.list<Session>(KV.sessions)
return { status_code: 200, body: { sessions } }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::sessions', config: { api_path: '/agentmemory/sessions', http_method: 'GET' } })
sdk.registerFunction(
{ id: 'api::observations' },
async (req: ApiRequest): Promise<Response> => {
const sessionId = req.query_params['sessionId'] as string
if (!sessionId) return { status_code: 400, body: { error: 'sessionId required' } }
const observations = await kv.list<CompressedObservation>(KV.observations(sessionId))
return { status_code: 200, body: { observations } }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::observations', config: { api_path: '/agentmemory/observations', http_method: 'GET' } })
sdk.registerFunction(
{ id: 'api::migrate' },
async (req: ApiRequest<{ dbPath: string }>): Promise<Response> => {
const result = await sdk.trigger('mem::migrate', req.body)
return { status_code: 200, body: result }
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::migrate', config: { api_path: '/agentmemory/migrate', http_method: 'POST' } })
sdk.registerFunction(
{ id: 'api::viewer' },
async (): Promise<Response> => {
try {
const viewerPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'viewer', 'index.html')
const html = readFileSync(viewerPath, 'utf-8')
return { status_code: 200, headers: { 'Content-Type': 'text/html' }, body: html }
} catch {
return { status_code: 200, headers: { 'Content-Type': 'text/html' }, body: '<!DOCTYPE html><html><body><h1>agentmemory</h1></body></html>' }
}
}
)
sdk.registerTrigger({ type: 'http', function_id: 'api::viewer', config: { api_path: '/agentmemory/viewer', http_method: 'GET' } })
}
+74
View File
@@ -0,0 +1,74 @@
import type { ISdk } from 'iii-sdk'
import { getContext } from 'iii-sdk'
import type { HookPayload } from '../types.js'
export function registerEventTriggers(sdk: ISdk): void {
sdk.registerFunction(
{ id: 'event::session::started', description: 'Handle session start event' },
async (data: { sessionId: string; project: string; cwd: string }) => {
const ctx = getContext()
ctx.logger.info('Session start event', { sessionId: data.sessionId })
return await sdk.trigger('api::session::start', {
body: data,
headers: {},
method: 'POST',
path_params: {},
query_params: {},
})
}
)
sdk.registerTrigger({
type: 'queue',
function_id: 'event::session::started',
config: { topic: 'agentmemory.session.started' },
})
sdk.registerFunction(
{ id: 'event::observation', description: 'Handle new observation event' },
async (data: HookPayload) => {
const ctx = getContext()
ctx.logger.info('Observation event', { sessionId: data.sessionId, hook: data.hookType })
return await sdk.trigger('mem::observe', data)
}
)
sdk.registerTrigger({
type: 'queue',
function_id: 'event::observation',
config: { topic: 'agentmemory.observation' },
})
sdk.registerFunction(
{ id: 'event::session::stopped', description: 'Handle stop event (trigger summarize)' },
async (data: { sessionId: string }) => {
const ctx = getContext()
ctx.logger.info('Session stop event, triggering summarize', { sessionId: data.sessionId })
return await sdk.trigger('mem::summarize', data)
}
)
sdk.registerTrigger({
type: 'queue',
function_id: 'event::session::stopped',
config: { topic: 'agentmemory.session.stopped' },
})
sdk.registerFunction(
{ id: 'event::session::ended', description: 'Handle session end event' },
async (data: { sessionId: string }) => {
const ctx = getContext()
ctx.logger.info('Session end event', { sessionId: data.sessionId })
return await sdk.trigger('api::session::end', {
body: data,
headers: {},
method: 'POST',
path_params: {},
query_params: {},
})
}
)
sdk.registerTrigger({
type: 'queue',
function_id: 'event::session::ended',
config: { topic: 'agentmemory.session.ended' },
})
}
+130
View File
@@ -0,0 +1,130 @@
export interface Session {
id: string;
project: string;
cwd: string;
startedAt: string;
endedAt?: string;
status: "active" | "completed" | "abandoned";
observationCount: number;
model?: string;
tags?: string[];
}
export interface RawObservation {
id: string;
sessionId: string;
timestamp: string;
hookType: HookType;
toolName?: string;
toolInput?: unknown;
toolOutput?: unknown;
userPrompt?: string;
assistantResponse?: string;
raw: unknown;
}
export interface CompressedObservation {
id: string;
sessionId: string;
timestamp: string;
type: ObservationType;
title: string;
subtitle?: string;
facts: string[];
narrative: string;
concepts: string[];
files: string[];
importance: number;
}
export type ObservationType =
| "file_read"
| "file_write"
| "file_edit"
| "command_run"
| "search"
| "web_fetch"
| "conversation"
| "error"
| "decision"
| "discovery"
| "other";
export interface Memory {
id: string;
createdAt: string;
updatedAt: string;
type: "pattern" | "preference" | "architecture" | "bug" | "workflow" | "fact";
title: string;
content: string;
concepts: string[];
files: string[];
sessionIds: string[];
strength: number;
}
export interface SessionSummary {
sessionId: string;
project: string;
createdAt: string;
title: string;
narrative: string;
keyDecisions: string[];
filesModified: string[];
concepts: string[];
observationCount: number;
}
export type HookType =
| "session_start"
| "prompt_submit"
| "post_tool_use"
| "stop"
| "session_end";
export interface HookPayload {
hookType: HookType;
sessionId: string;
project: string;
cwd: string;
timestamp: string;
data: unknown;
}
export interface ProviderConfig {
provider: ProviderType;
model: string;
maxTokens: number;
}
export type ProviderType = "agent-sdk" | "anthropic" | "gemini" | "openrouter";
export interface MemoryProvider {
name: string;
compress(systemPrompt: string, userPrompt: string): Promise<string>;
summarize(systemPrompt: string, userPrompt: string): Promise<string>;
}
export interface AgentMemoryConfig {
engineUrl: string;
restPort: number;
streamsPort: number;
provider: ProviderConfig;
tokenBudget: number;
maxObservationsPerSession: number;
compressionModel: string;
dataDir: string;
}
export interface SearchResult {
observation: CompressedObservation;
score: number;
sessionId: string;
}
export interface ContextBlock {
type: "summary" | "observation" | "memory";
content: string;
tokens: number;
recency: number;
}
+211
View File
@@ -0,0 +1,211 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>agentmemory viewer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
background: #0d1117;
color: #c9d1d9;
line-height: 1.5;
}
.header {
padding: 16px 24px;
border-bottom: 1px solid #21262d;
display: flex;
align-items: center;
justify-content: space-between;
}
.header h1 { font-size: 16px; color: #58a6ff; font-weight: 600; }
.header .status { font-size: 12px; padding: 2px 8px; border-radius: 12px; }
.status.connected { background: #1b4332; color: #40c057; }
.status.disconnected { background: #3d1f1f; color: #f85149; }
.controls {
padding: 12px 24px;
border-bottom: 1px solid #21262d;
display: flex;
gap: 12px;
align-items: center;
}
.controls select, .controls input {
background: #161b22;
border: 1px solid #30363d;
color: #c9d1d9;
padding: 6px 10px;
border-radius: 6px;
font-family: inherit;
font-size: 13px;
}
.controls input { flex: 1; }
.timeline { padding: 16px 24px; overflow-y: auto; height: calc(100vh - 120px); }
.obs {
margin-bottom: 12px;
padding: 12px 16px;
background: #161b22;
border: 1px solid #21262d;
border-radius: 8px;
border-left: 3px solid #30363d;
}
.obs.importance-high { border-left-color: #f85149; }
.obs.importance-med { border-left-color: #d29922; }
.obs.importance-low { border-left-color: #3fb950; }
.obs-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
.obs-type {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: #21262d;
color: #8b949e;
text-transform: uppercase;
}
.obs-time { font-size: 11px; color: #484f58; }
.obs-title { font-size: 14px; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }
.obs-narrative { font-size: 13px; color: #8b949e; }
.obs-facts { margin-top: 6px; padding-left: 16px; }
.obs-facts li { font-size: 12px; color: #7d8590; margin-bottom: 2px; }
.obs-meta { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: #1f2937;
color: #58a6ff;
}
.file-tag { color: #3fb950; }
.empty {
text-align: center;
padding: 60px 0;
color: #484f58;
}
.raw-badge {
background: #3d2b00;
color: #d29922;
}
</style>
</head>
<body>
<div class="header">
<h1>agentmemory</h1>
<span id="status" class="status disconnected">disconnected</span>
</div>
<div class="controls">
<select id="session-filter">
<option value="">All sessions</option>
</select>
<input id="search" type="text" placeholder="Search observations...">
</div>
<div id="timeline" class="timeline">
<div class="empty">Waiting for observations...</div>
</div>
<script>
const REST = 'http://localhost:3111';
const WS_URL = 'ws://localhost:3112';
const timeline = document.getElementById('timeline');
const statusEl = document.getElementById('status');
const searchEl = document.getElementById('search');
const sessionFilter = document.getElementById('session-filter');
let observations = [];
let ws = null;
function connect() {
try {
ws = new WebSocket(WS_URL);
ws.onopen = () => {
statusEl.textContent = 'connected';
statusEl.className = 'status connected';
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.observation) {
const existing = observations.findIndex(o => o.id === msg.observation.id);
if (existing >= 0) {
observations[existing] = msg.observation;
} else {
observations.unshift(msg.observation);
}
render();
}
} catch {}
};
ws.onclose = () => {
statusEl.textContent = 'disconnected';
statusEl.className = 'status disconnected';
setTimeout(connect, 3000);
};
ws.onerror = () => ws.close();
} catch {
setTimeout(connect, 3000);
}
}
async function loadSessions() {
try {
const res = await fetch(`${REST}/agentmemory/sessions`);
const data = await res.json();
for (const s of data.sessions || []) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.project.split('/').pop()} (${s.id.slice(0, 8)})`;
sessionFilter.appendChild(opt);
}
} catch {}
}
function render() {
const query = searchEl.value.toLowerCase();
const session = sessionFilter.value;
let filtered = observations;
if (session) filtered = filtered.filter(o => o.sessionId === session);
if (query) filtered = filtered.filter(o =>
(o.title || '').toLowerCase().includes(query) ||
(o.narrative || '').toLowerCase().includes(query) ||
(o.concepts || []).some(c => c.toLowerCase().includes(query))
);
if (filtered.length === 0) {
timeline.innerHTML = '<div class="empty">No observations match</div>';
return;
}
timeline.innerHTML = filtered.map(o => {
const imp = (o.importance || 5) >= 7 ? 'high' : (o.importance || 5) >= 4 ? 'med' : 'low';
const isRaw = !o.title;
const title = o.title || o.toolName || o.hookType || 'Raw observation';
const type = o.type || o.hookType || 'raw';
const time = o.timestamp ? new Date(o.timestamp).toLocaleTimeString() : '';
const narrative = o.narrative || '';
const facts = (o.facts || []).map(f => `<li>${esc(f)}</li>`).join('');
const concepts = (o.concepts || []).map(c => `<span class="tag">${esc(c)}</span>`).join('');
const files = (o.files || []).map(f => `<span class="tag file-tag">${esc(f)}</span>`).join('');
return `<div class="obs importance-${imp}">
<div class="obs-header">
<span class="obs-type ${isRaw ? 'raw-badge' : ''}">${esc(type)}</span>
<span class="obs-time">${time}</span>
</div>
<div class="obs-title">${esc(title)}</div>
${narrative ? `<div class="obs-narrative">${esc(narrative)}</div>` : ''}
${facts ? `<ul class="obs-facts">${facts}</ul>` : ''}
${concepts || files ? `<div class="obs-meta">${concepts}${files}</div>` : ''}
</div>`;
}).join('');
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
searchEl.addEventListener('input', render);
sessionFilter.addEventListener('change', render);
loadSessions();
connect();
</script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "src/hooks"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from "tsdown";
export default defineConfig([
{
entry: ["src/index.ts"],
outDir: "dist",
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
target: "node18",
banner: { js: "#!/usr/bin/env node" },
},
{
entry: [
"src/hooks/session-start.ts",
"src/hooks/prompt-submit.ts",
"src/hooks/post-tool-use.ts",
"src/hooks/stop.ts",
"src/hooks/session-end.ts",
],
outDir: "dist/hooks",
format: ["esm"],
clean: false,
sourcemap: false,
target: "node18",
},
]);