feat: agentmemory v0.1.0 -- persistent memory for AI coding agents
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
Generated
+3724
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ?? ''
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' } })
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
Reference in New Issue
Block a user