Compare commits
2
Commits
v0.1.0-rc.1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4e56db488 | ||
|
|
cd09ed531d |
No files matched your search
@@ -30,9 +30,14 @@
|
|||||||
modified files; preserve JSONC comments and unrelated configuration.
|
modified files; preserve JSONC comments and unrelated configuration.
|
||||||
- Keep state scoped by project/session. Do not introduce a mutable module-level
|
- Keep state scoped by project/session. Do not introduce a mutable module-level
|
||||||
store for user session data.
|
store for user session data.
|
||||||
|
- Optional memory uses `ctx.storage` with project/session-derived key prefixes;
|
||||||
|
it is disabled by default and never auto-captures or injects model context.
|
||||||
|
- Memory expiry is lazy (on access), not a background cleanup task; storage is
|
||||||
|
not encrypted by this package.
|
||||||
- Every imported ECC file must have source, revision, attribution, and hash
|
- Every imported ECC file must have source, revision, attribution, and hash
|
||||||
recorded in `docs/PROVENANCE.md`.
|
recorded in `docs/PROVENANCE.md`.
|
||||||
- Tests do not prove provider-backed agent behavior; report that separately.
|
- Tests do not prove provider-backed agent behavior or persistence in a live
|
||||||
|
OpenCode profile; report those separately.
|
||||||
- `npm test` compiles into ignored `dist/`; `npm pack --dry-run` runs the
|
- `npm test` compiles into ignored `dist/`; `npm pack --dry-run` runs the
|
||||||
`prepack` build hook and therefore writes generated output.
|
`prepack` build hook and therefore writes generated output.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented here.
|
All notable changes to this project are documented here.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Add opt-in session and project memory tools backed by OpenCode plugin storage,
|
||||||
|
with explicit retrieval, deletion, project/session isolation, and expiry.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Document memory opt-in, scope, retention, storage limitations, and uninstall
|
||||||
|
behavior for plugin entries with user options.
|
||||||
|
|
||||||
## [0.1.0-rc.1] - 2026-10-08
|
## [0.1.0-rc.1] - 2026-10-08
|
||||||
|
|
||||||
### Distribution
|
### Distribution
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
- [ ] Provider-backed workflows (agent delegation and actual skill invocation)
|
- [ ] Provider-backed workflows (agent delegation and actual skill invocation)
|
||||||
have not been tested; they require configured credentials and explicit
|
have not been tested; they require configured credentials and explicit
|
||||||
approval of any model usage.
|
approval of any model usage.
|
||||||
|
- [ ] Optional memory has deterministic tests but has not been checked for
|
||||||
|
persistence and isolation across restarts in a live OpenCode 2.0.24 profile.
|
||||||
|
Provider-backed learning proposals are not implemented.
|
||||||
- [ ] Global/XDG installation is not implemented; project-level update and
|
- [ ] Global/XDG installation is not implemented; project-level update and
|
||||||
uninstall are supported, while global installation remains out of scope.
|
uninstall are supported, while global installation remains out of scope.
|
||||||
- [ ] The package name and GitHub URL are provisional and must be confirmed
|
- [ ] The package name and GitHub URL are provisional and must be confirmed
|
||||||
|
|||||||
@@ -14,12 +14,56 @@ to the repository release.
|
|||||||
- Subagents: planner, code reviewer, and security reviewer.
|
- Subagents: planner, code reviewer, and security reviewer.
|
||||||
- Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS
|
- Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS
|
||||||
API and exposed in the `toolkit` Code Mode namespace.
|
API and exposed in the `toolkit` Code Mode namespace.
|
||||||
|
- Optional memory tools, disabled by default. Session memory is isolated by
|
||||||
|
project and session; shared project memory requires a separate opt-in.
|
||||||
- Project installer with JSONC-preserving plugin registration, file ownership
|
- Project installer with JSONC-preserving plugin registration, file ownership
|
||||||
hashes, collision checks, and protection for locally modified files.
|
hashes, collision checks, and protection for locally modified files.
|
||||||
|
|
||||||
Provider/model selection remains the user's OpenCode configuration. The package
|
Provider/model selection remains the user's OpenCode configuration. The package
|
||||||
does not enable hooks through a global install or configure credentials.
|
does not enable hooks through a global install or configure credentials.
|
||||||
|
|
||||||
|
## Optional memory
|
||||||
|
|
||||||
|
Memory is off unless enabled in the consuming project's plugin options. To turn
|
||||||
|
on session memory, convert the installer-added plugin string into an object
|
||||||
|
entry, keeping its existing local package path:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"package": "./node_modules/@sargatxet/opencode-toolkit",
|
||||||
|
"options": {
|
||||||
|
"memory": {
|
||||||
|
"enabled": true,
|
||||||
|
"retentionDays": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the project uses `.opencode/opencode.json`, retain the different relative
|
||||||
|
path the installer registered (typically `../node_modules/...`). The toolkit
|
||||||
|
does not automatically capture prompts, tool results, or session summaries.
|
||||||
|
Use `toolkit_memory_save`, `toolkit_memory_list`, `toolkit_memory_get`, and
|
||||||
|
`toolkit_memory_delete` explicitly. Retrieval and list previews are returned to
|
||||||
|
the current model session as tool output. Memory content is stored through
|
||||||
|
OpenCode's plugin storage; do not store credentials or secrets.
|
||||||
|
|
||||||
|
Session scope is the default and is limited to the current project/session.
|
||||||
|
Project-wide sharing remains disabled unless the options also contain
|
||||||
|
`"project": true` inside `memory`. Project memories are shared by sessions and
|
||||||
|
worktrees belonging to the same canonical OpenCode project. Retention defaults
|
||||||
|
to 30 days and can be set from 1 to 3650 days. Expired records are not returned
|
||||||
|
and are deleted when accessed; there is no background cleanup job. Delete
|
||||||
|
individual records explicitly with `toolkit_memory_delete`.
|
||||||
|
|
||||||
|
Uninstall preserves a plugin object entry because it may contain user options.
|
||||||
|
If you enabled memory by converting the installer-added string to an object,
|
||||||
|
remove that plugin entry from the project config yourself when uninstalling.
|
||||||
|
|
||||||
## Install the pre-release
|
## Install the pre-release
|
||||||
|
|
||||||
From the project where you want to use the toolkit, run:
|
From the project where you want to use the toolkit, run:
|
||||||
@@ -93,6 +137,9 @@ the toolkit plugin entry should be removed. These install/uninstall checks need
|
|||||||
no model credentials. Plugin activation and tool execution were separately
|
no model credentials. Plugin activation and tool execution were separately
|
||||||
smoke-tested with OpenCode 2.0.24 and a local mock OpenAI-compatible endpoint.
|
smoke-tested with OpenCode 2.0.24 and a local mock OpenAI-compatible endpoint.
|
||||||
Provider-backed agent delegation and skill behavior remain unverified.
|
Provider-backed agent delegation and skill behavior remain unverified.
|
||||||
|
Memory behavior is covered by deterministic storage-contract tests; persistence
|
||||||
|
inside a running OpenCode profile and provider-backed learning flows are not yet
|
||||||
|
verified.
|
||||||
|
|
||||||
## Safety boundaries
|
## Safety boundaries
|
||||||
|
|
||||||
|
|||||||
@@ -26,3 +26,16 @@
|
|||||||
- Verify plugin activation and tool execution on OpenCode 2.0.24 using a local
|
- Verify plugin activation and tool execution on OpenCode 2.0.24 using a local
|
||||||
mock OpenAI-compatible endpoint; do not make a paid or remote provider call as
|
mock OpenAI-compatible endpoint; do not make a paid or remote provider call as
|
||||||
part of deterministic validation.
|
part of deterministic validation.
|
||||||
|
|
||||||
|
## Optional memory
|
||||||
|
|
||||||
|
- Memory tools must not register unless explicitly enabled in plugin options.
|
||||||
|
- Session memory is scoped to the current OpenCode project and session; project
|
||||||
|
memory is separately opt-in and shared only within the same canonical project.
|
||||||
|
- Memory must be written, listed, retrieved, and deleted only through explicit
|
||||||
|
tool calls; prompts, tool outputs, and summaries are not captured automatically.
|
||||||
|
- Apply a configurable expiration (default 30 days); expired entries must never
|
||||||
|
be returned, and memory content must not be automatically injected into future
|
||||||
|
model context or uploaded by the plugin.
|
||||||
|
- Reject memory operations for sessions belonging to another project. Provider-
|
||||||
|
backed learning and live-profile persistence require separate verification.
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
credentials, including idempotency and conservative uninstall checks.
|
credentials, including idempotency and conservative uninstall checks.
|
||||||
- [x] Perform and record a pre-release manual security review of installer paths,
|
- [x] Perform and record a pre-release manual security review of installer paths,
|
||||||
file ownership, configuration edits, and failure limitations.
|
file ownership, configuration edits, and failure limitations.
|
||||||
- [ ] Publish the `0.1.0-rc.1` tarball as a repository pre-release and verify the
|
- [x] Publish the `0.1.0-rc.1` tarball and SHA-256 as a repository pre-release;
|
||||||
direct installation URL from a clean project.
|
install from its public URL in a clean project and verify the checksum.
|
||||||
- [ ] Add provider-backed verification after confirming credentials and model
|
- [ ] Add provider-backed verification after confirming credentials and model
|
||||||
usage budget with the user.
|
usage budget with the user.
|
||||||
- [ ] Decide whether to publish the package to npm; repository pre-releases do
|
- [ ] Decide whether to publish the package to npm; repository pre-releases do
|
||||||
@@ -34,6 +34,10 @@
|
|||||||
|
|
||||||
## Follow-up
|
## Follow-up
|
||||||
|
|
||||||
|
- [ ] Verify plugin-storage persistence and session/project isolation in a live
|
||||||
|
OpenCode 2.0.24 profile; design opt-in learning proposals as a separate step.
|
||||||
|
- [ ] Decide whether memory needs a bulk-clear command and background expiry
|
||||||
|
cleanup before adding automatic learning.
|
||||||
- [ ] Add a diagnostic command for installation state and supported versions.
|
- [ ] Add a diagnostic command for installation state and supported versions.
|
||||||
- [ ] Evaluate global/XDG installation only if project-local usage proves
|
- [ ] Evaluate global/XDG installation only if project-local usage proves
|
||||||
insufficient.
|
insufficient.
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ provider-backed agent delegation and skill behavior remain unverified.
|
|||||||
runtime; config collisions are handled or reported before writes; the V2 plugin
|
runtime; config collisions are handled or reported before writes; the V2 plugin
|
||||||
and its custom tool are confirmed active.
|
and its custom tool are confirmed active.
|
||||||
|
|
||||||
### Phase 5 — Document and release (repository pre-release in progress)
|
### Phase 5 — Document and release (repository pre-release published)
|
||||||
|
|
||||||
- Document scope, installation, update/uninstall, compatibility, provenance,
|
- Document scope, installation, update/uninstall, compatibility, provenance,
|
||||||
and known limitations.
|
and known limitations.
|
||||||
@@ -151,21 +151,32 @@ pre-release tarball, as well as updating and uninstalling. A manual pre-release
|
|||||||
review of installer paths, ownership, JSONC edits, symlinks, and failure behavior
|
review of installer paths, ownership, JSONC edits, symlinks, and failure behavior
|
||||||
found no release blocker for trusted project-local use; filesystem and
|
found no release blocker for trusted project-local use; filesystem and
|
||||||
provider-backed limitations are recorded in [`SECURITY-REVIEW.md`](SECURITY-REVIEW.md).
|
provider-backed limitations are recorded in [`SECURITY-REVIEW.md`](SECURITY-REVIEW.md).
|
||||||
Typecheck, all 16 tests, coverage (87.4% lines), and package dry-run passed.
|
Typecheck, all 16 tests, coverage (87.4% lines), and package build passed. The
|
||||||
The `0.1.0-rc.1` tarball and repository release still need publication and a
|
`v0.1.0-rc.1` pre-release and SHA-256 sidecar are published. A clean project
|
||||||
clean-project install check.
|
installed the package using the public release URL, and the downloaded tarball
|
||||||
|
matched its recorded SHA-256.
|
||||||
|
|
||||||
**Acceptance:** another user can install from documented instructions and
|
**Acceptance:** another user can install from documented instructions and
|
||||||
verify the deterministic behavior without credentials.
|
verify the deterministic behavior without credentials.
|
||||||
|
|
||||||
### Phase 6 — Optional memory and learning features (not started)
|
### Phase 6 — Optional memory and learning features (memory milestone implemented)
|
||||||
|
|
||||||
- Design persistent memory and session-learning APIs separately.
|
- Design persistent memory and session-learning APIs separately.
|
||||||
- Prefer V2 public APIs over assumptions about OpenCode's storage layout.
|
- Prefer V2 public APIs over assumptions about OpenCode's storage layout.
|
||||||
- Add opt-in, scope controls, retention behavior, and isolation tests.
|
- Add opt-in, scope controls, retention behavior, and isolation tests.
|
||||||
|
|
||||||
|
**Progress:** explicit memory tools now use the V2 plugin storage API. They are
|
||||||
|
disabled by default, session-scoped by project/session, and support separately
|
||||||
|
opted-in canonical-project scope, explicit retrieval/deletion, and configurable
|
||||||
|
expiry. Deterministic tests cover scope isolation and expiry. Live profile
|
||||||
|
storage persistence and user-profile isolation, along with provider-backed
|
||||||
|
learning proposals, remain unverified; automatic prompt/session capture is
|
||||||
|
deliberately not implemented.
|
||||||
|
|
||||||
**Acceptance:** explicit consent and tests prove project/session/user data
|
**Acceptance:** explicit consent and tests prove project/session/user data
|
||||||
cannot be mixed or silently uploaded.
|
cannot be mixed or silently uploaded. This phase remains in progress until
|
||||||
|
storage behavior is verified in OpenCode 2.0.24 and learning proposals receive a
|
||||||
|
separate design and validation.
|
||||||
|
|
||||||
## Verification strategy
|
## Verification strategy
|
||||||
|
|
||||||
|
|||||||
@@ -42,3 +42,23 @@ files, symlinked install directories, and a tampered manifest.
|
|||||||
branches.
|
branches.
|
||||||
- `npm pack --dry-run` — passed; included runtime files, selected content,
|
- `npm pack --dry-run` — passed; included runtime files, selected content,
|
||||||
licenses, notice, and README.
|
licenses, notice, and README.
|
||||||
|
|
||||||
|
## Optional memory review (2026-10-08)
|
||||||
|
|
||||||
|
Memory tools are not registered unless `memory.enabled` is explicitly true.
|
||||||
|
Session keys include hashes of the OpenCode project identity and session ID;
|
||||||
|
project-scope access additionally requires `memory.project: true` and is keyed
|
||||||
|
to the canonical project. Each operation checks the session's project ID. Data
|
||||||
|
is only stored or returned through explicit tool calls; list previews and
|
||||||
|
retrieval become ordinary tool output visible to the active provider. The
|
||||||
|
package does not encrypt storage, capture session content, or background-upload
|
||||||
|
memory. Retention is lazy: expired entries are rejected and removed on access,
|
||||||
|
not by a scheduled purge. Provider-backed learning and persistence across live
|
||||||
|
OpenCode restarts have not been verified, so this is not a security audit or a
|
||||||
|
claim about the underlying storage implementation.
|
||||||
|
|
||||||
|
Deterministic checks cover disabled-by-default behavior, session separation,
|
||||||
|
project opt-in, project separation, cross-project rejection, explicit deletion,
|
||||||
|
and expiry. A plugin config object containing user options is preserved during
|
||||||
|
uninstall; users must remove that entry themselves if they converted the
|
||||||
|
installer-added string to configure memory.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 0001. Store explicit memory through the V2 plugin storage API
|
||||||
|
|
||||||
|
Date: 2026-10-08
|
||||||
|
Status: proposed
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The toolkit needs persistent memory without assuming OpenCode's on-disk storage
|
||||||
|
layout, silently collecting session data, or mixing sessions and projects.
|
||||||
|
OpenCode 2.0.24 exposes plugin-scoped storage and supplies the active tool
|
||||||
|
session ID and session project/location through its public plugin API.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use `ctx.storage` for explicitly invoked memory operations. Disable memory unless
|
||||||
|
the plugin's `memory.enabled` option is true; default writes to a key namespace
|
||||||
|
derived from project identity and session ID. Permit canonical-project scope
|
||||||
|
only when `memory.project` is separately enabled. Return content only through an
|
||||||
|
explicit retrieval/list tool call, apply a configurable expiry, and do not
|
||||||
|
capture prompts or automatically inject stored content into model context.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The implementation avoids dependence on private OpenCode database paths and
|
||||||
|
makes opt-in boundaries visible in project configuration.
|
||||||
|
- Project/session key namespaces limit accidental data mixing, while project
|
||||||
|
sharing deliberately spans sessions and worktrees with the same canonical
|
||||||
|
project identity.
|
||||||
|
- Storage encryption, user-profile isolation, and persistence across a live
|
||||||
|
OpenCode restart rely on the host implementation and remain unverified.
|
||||||
|
- Lazy expiration does not immediately reclaim inaccessible expired records;
|
||||||
|
there is no background cleanup or automatic learning workflow.
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto"
|
||||||
|
|
||||||
|
export type MemoryScope = "session" | "project"
|
||||||
|
|
||||||
|
export interface MemoryRecord {
|
||||||
|
[key: string]: string
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
scope: MemoryScope
|
||||||
|
createdAt: string
|
||||||
|
expiresAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StorageEntry {
|
||||||
|
key: string
|
||||||
|
value: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryStorage {
|
||||||
|
get(key: string): Promise<unknown>
|
||||||
|
set(key: string, value: MemoryRecord): Promise<void>
|
||||||
|
remove(key: string): Promise<void>
|
||||||
|
scan(options: { prefix: string; after?: string; limit?: number }): Promise<{
|
||||||
|
entries: readonly StorageEntry[]
|
||||||
|
next?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemorySession {
|
||||||
|
projectID: string
|
||||||
|
location: { directory: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryStoreOptions {
|
||||||
|
storage: MemoryStorage
|
||||||
|
projectID: string
|
||||||
|
projectCanonical: string
|
||||||
|
projectMemoryEnabled: boolean
|
||||||
|
retentionDays: number
|
||||||
|
getSession(sessionID: string): Promise<MemorySession>
|
||||||
|
now?: () => Date
|
||||||
|
createID?: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CONTENT_LENGTH = 16_000
|
||||||
|
const MAX_LIST_LIMIT = 100
|
||||||
|
const STORAGE_PAGE_SIZE = 100
|
||||||
|
const MAX_LIST_PAGES = 5
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
function digest(value: string): string {
|
||||||
|
return createHash("sha256").update(value).digest("hex")
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMemoryScope(value: unknown): value is MemoryScope {
|
||||||
|
return value === "session" || value === "project"
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMemoryRecord(value: unknown): MemoryRecord | undefined {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined
|
||||||
|
const record = value as Partial<MemoryRecord>
|
||||||
|
if (
|
||||||
|
typeof record.id !== "string"
|
||||||
|
|| !/^[0-9a-f-]{36}$/i.test(record.id)
|
||||||
|
|| typeof record.content !== "string"
|
||||||
|
|| !isMemoryScope(record.scope)
|
||||||
|
|| typeof record.createdAt !== "string"
|
||||||
|
|| typeof record.expiresAt !== "string"
|
||||||
|
|| !Number.isFinite(Date.parse(record.createdAt))
|
||||||
|
|| !Number.isFinite(Date.parse(record.expiresAt))
|
||||||
|
) return undefined
|
||||||
|
return record as MemoryRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMemoryStore(options: MemoryStoreOptions) {
|
||||||
|
const projectKey = digest(`${options.projectID}\0${options.projectCanonical}`)
|
||||||
|
const now = options.now ?? (() => new Date())
|
||||||
|
const createID = options.createID ?? randomUUID
|
||||||
|
|
||||||
|
async function prefixFor(sessionID: string, scope: MemoryScope): Promise<string> {
|
||||||
|
const session = await options.getSession(sessionID)
|
||||||
|
if (session.projectID !== options.projectID || session.location.directory.length === 0) {
|
||||||
|
throw new Error("Memory is only available to sessions in the current project")
|
||||||
|
}
|
||||||
|
if (scope === "project") {
|
||||||
|
if (!options.projectMemoryEnabled) {
|
||||||
|
throw new Error("Project memory is disabled; enable it explicitly in plugin options")
|
||||||
|
}
|
||||||
|
return `toolkit/memory/v1/${projectKey}/project/`
|
||||||
|
}
|
||||||
|
return `toolkit/memory/v1/${projectKey}/session/${digest(sessionID)}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateContent(content: unknown): asserts content is string {
|
||||||
|
if (typeof content !== "string" || content.trim().length === 0) {
|
||||||
|
throw new Error("Memory content must be a non-empty string")
|
||||||
|
}
|
||||||
|
if (content.length > MAX_CONTENT_LENGTH) {
|
||||||
|
throw new Error(`Memory content must be ${MAX_CONTENT_LENGTH} characters or fewer`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLimit(limit: number | undefined): number {
|
||||||
|
const requested = limit ?? 20
|
||||||
|
if (!Number.isInteger(requested) || requested < 1 || requested > MAX_LIST_LIMIT) {
|
||||||
|
throw new Error(`Memory list limit must be an integer from 1 to ${MAX_LIST_LIMIT}`)
|
||||||
|
}
|
||||||
|
return requested
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(input: { sessionID: string; scope: MemoryScope; content: string }): Promise<MemoryRecord> {
|
||||||
|
validateContent(input.content)
|
||||||
|
const prefix = await prefixFor(input.sessionID, input.scope)
|
||||||
|
const created = now()
|
||||||
|
const record: MemoryRecord = {
|
||||||
|
id: createID(),
|
||||||
|
content: input.content,
|
||||||
|
scope: input.scope,
|
||||||
|
createdAt: created.toISOString(),
|
||||||
|
expiresAt: new Date(created.getTime() + options.retentionDays * DAY_MS).toISOString(),
|
||||||
|
}
|
||||||
|
await options.storage.set(`${prefix}${record.id}`, record)
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(input: { sessionID: string; scope: MemoryScope; id: string }): Promise<MemoryRecord | undefined> {
|
||||||
|
const prefix = await prefixFor(input.sessionID, input.scope)
|
||||||
|
const key = `${prefix}${input.id}`
|
||||||
|
const record = parseMemoryRecord(await options.storage.get(key))
|
||||||
|
if (!record || record.id !== input.id || record.scope !== input.scope) return undefined
|
||||||
|
if (Date.parse(record.expiresAt) <= now().getTime()) {
|
||||||
|
await options.storage.remove(key)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list(input: {
|
||||||
|
sessionID: string
|
||||||
|
scope: MemoryScope
|
||||||
|
limit?: number
|
||||||
|
}): Promise<MemoryRecord[]> {
|
||||||
|
const prefix = await prefixFor(input.sessionID, input.scope)
|
||||||
|
const limit = validateLimit(input.limit)
|
||||||
|
const records: MemoryRecord[] = []
|
||||||
|
let after: string | undefined
|
||||||
|
|
||||||
|
for (let pageNumber = 0; pageNumber < MAX_LIST_PAGES; pageNumber++) {
|
||||||
|
const page = await options.storage.scan({ prefix, ...(after === undefined ? {} : { after }), limit: STORAGE_PAGE_SIZE })
|
||||||
|
for (const entry of page.entries) {
|
||||||
|
const record = parseMemoryRecord(entry.value)
|
||||||
|
if (!record || record.scope !== input.scope || entry.key !== `${prefix}${record.id}`) continue
|
||||||
|
if (Date.parse(record.expiresAt) <= now().getTime()) {
|
||||||
|
await options.storage.remove(entry.key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records.push(record)
|
||||||
|
}
|
||||||
|
if (!page.next || records.length >= limit) break
|
||||||
|
after = page.next
|
||||||
|
}
|
||||||
|
|
||||||
|
return records.sort((left, right) => right.createdAt.localeCompare(left.createdAt)).slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(input: { sessionID: string; scope: MemoryScope; id: string }): Promise<boolean> {
|
||||||
|
const record = await get(input)
|
||||||
|
if (!record) return false
|
||||||
|
const prefix = await prefixFor(input.sessionID, input.scope)
|
||||||
|
await options.storage.remove(`${prefix}${input.id}`)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { save, get, list, remove }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEMORY_LIMITS = {
|
||||||
|
maxContentLength: MAX_CONTENT_LENGTH,
|
||||||
|
maxListLimit: MAX_LIST_LIMIT,
|
||||||
|
} as const
|
||||||
+144
@@ -1,10 +1,152 @@
|
|||||||
import { Plugin } from "@opencode/plugin"
|
import { Plugin } from "@opencode/plugin"
|
||||||
|
import { createMemoryStore, MEMORY_LIMITS, type MemoryScope } from "./memory.js"
|
||||||
|
|
||||||
export const TOOLKIT_PLUGIN_ID = "sargatxet.opencode-toolkit"
|
export const TOOLKIT_PLUGIN_ID = "sargatxet.opencode-toolkit"
|
||||||
|
|
||||||
|
interface MemoryOptions {
|
||||||
|
enabled: boolean
|
||||||
|
project: boolean
|
||||||
|
retentionDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryOptions(value: unknown): MemoryOptions {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return { enabled: false, project: false, retentionDays: 30 }
|
||||||
|
}
|
||||||
|
const options = value as Record<string, unknown>
|
||||||
|
if (options.enabled !== true) return { enabled: false, project: false, retentionDays: 30 }
|
||||||
|
|
||||||
|
const retentionDays = options.retentionDays ?? 30
|
||||||
|
if (typeof retentionDays !== "number" || !Number.isInteger(retentionDays) || retentionDays < 1 || retentionDays > 3650) {
|
||||||
|
throw new Error("Memory retentionDays must be an integer from 1 to 3650")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
project: options.project === true,
|
||||||
|
retentionDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerMemoryTools(
|
||||||
|
editor: Parameters<Parameters<Plugin.Context["tool"]["transform"]>[0]>[0],
|
||||||
|
store: ReturnType<typeof createMemoryStore>,
|
||||||
|
): void {
|
||||||
|
editor.add({
|
||||||
|
name: "memory_save",
|
||||||
|
description: "Save a memory explicitly for this session, or to the project when project memory is enabled.",
|
||||||
|
input: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
content: { type: "string", description: `Memory content, up to ${MEMORY_LIMITS.maxContentLength} characters.` },
|
||||||
|
scope: { type: "string", enum: ["session", "project"], description: "Defaults to session. Project scope requires explicit configuration." },
|
||||||
|
},
|
||||||
|
required: ["content"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
options: { namespace: "toolkit", codemode: true },
|
||||||
|
execute: async (input, execution) => {
|
||||||
|
const args = input as { content: string; scope?: MemoryScope }
|
||||||
|
const record = await store.save({
|
||||||
|
sessionID: execution.sessionID,
|
||||||
|
scope: args.scope ?? "session",
|
||||||
|
content: args.content,
|
||||||
|
})
|
||||||
|
return { content: JSON.stringify({ id: record.id, scope: record.scope, createdAt: record.createdAt, expiresAt: record.expiresAt }) }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.add({
|
||||||
|
name: "memory_list",
|
||||||
|
description: "List saved memories in the current session or enabled project scope. Content previews are shown to this session.",
|
||||||
|
input: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
scope: { type: "string", enum: ["session", "project"] },
|
||||||
|
limit: { type: "number", description: `Maximum results, from 1 to ${MEMORY_LIMITS.maxListLimit}. Defaults to 20.` },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
options: { namespace: "toolkit", codemode: true },
|
||||||
|
execute: async (input, execution) => {
|
||||||
|
const args = input as { scope?: MemoryScope; limit?: number }
|
||||||
|
const records = await store.list({
|
||||||
|
sessionID: execution.sessionID,
|
||||||
|
scope: args.scope ?? "session",
|
||||||
|
...(args.limit === undefined ? {} : { limit: args.limit }),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
content: JSON.stringify(records.map(({ id, content, scope, createdAt, expiresAt }) => ({
|
||||||
|
id,
|
||||||
|
scope,
|
||||||
|
preview: content.slice(0, 180),
|
||||||
|
createdAt,
|
||||||
|
expiresAt,
|
||||||
|
})), null, 2),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.add({
|
||||||
|
name: "memory_get",
|
||||||
|
description: "Retrieve one explicitly selected saved memory into the current session context.",
|
||||||
|
input: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
scope: { type: "string", enum: ["session", "project"] },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
options: { namespace: "toolkit", codemode: true },
|
||||||
|
execute: async (input, execution) => {
|
||||||
|
const args = input as { id: string; scope?: MemoryScope }
|
||||||
|
const record = await store.get({ sessionID: execution.sessionID, scope: args.scope ?? "session", id: args.id })
|
||||||
|
if (!record) return { content: "Memory not found or expired." }
|
||||||
|
return { content: JSON.stringify(record, null, 2) }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
editor.add({
|
||||||
|
name: "memory_delete",
|
||||||
|
description: "Delete one explicitly selected saved memory from the current session or enabled project scope.",
|
||||||
|
input: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
scope: { type: "string", enum: ["session", "project"] },
|
||||||
|
},
|
||||||
|
required: ["id"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
options: { namespace: "toolkit", codemode: true },
|
||||||
|
execute: async (input, execution) => {
|
||||||
|
const args = input as { id: string; scope?: MemoryScope }
|
||||||
|
const deleted = await store.remove({ sessionID: execution.sessionID, scope: args.scope ?? "session", id: args.id })
|
||||||
|
return { content: deleted ? "Memory deleted." : "Memory not found or already expired." }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export default Plugin.define({
|
export default Plugin.define({
|
||||||
id: TOOLKIT_PLUGIN_ID,
|
id: TOOLKIT_PLUGIN_ID,
|
||||||
async setup(ctx) {
|
async setup(ctx) {
|
||||||
|
const memoryConfig = memoryOptions(ctx.options.memory)
|
||||||
|
const memoryStore = memoryConfig.enabled
|
||||||
|
? createMemoryStore({
|
||||||
|
storage: ctx.storage,
|
||||||
|
projectID: ctx.location.project.id,
|
||||||
|
projectCanonical: ctx.location.project.canonical,
|
||||||
|
projectMemoryEnabled: memoryConfig.project,
|
||||||
|
retentionDays: memoryConfig.retentionDays,
|
||||||
|
getSession: async (sessionID) => {
|
||||||
|
const session = await ctx.session.get({ sessionID })
|
||||||
|
return { projectID: session.projectID, location: { directory: session.location.directory } }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
|
||||||
await ctx.tool.transform((editor) => {
|
await ctx.tool.transform((editor) => {
|
||||||
editor.namespace({
|
editor.namespace({
|
||||||
name: "toolkit",
|
name: "toolkit",
|
||||||
@@ -31,6 +173,8 @@ export default Plugin.define({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (memoryStore) registerMemoryTools(editor, memoryStore)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -28,6 +28,14 @@ test("does not duplicate an already configured package plugin", () => {
|
|||||||
assert.equal(result.content, original)
|
assert.equal(result.content, original)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("recognizes an object-form plugin entry without replacing its options", () => {
|
||||||
|
const original = JSON.stringify({ plugins: [{ package: pluginName, options: { memory: { enabled: true } } }] }, null, 2)
|
||||||
|
const result = addPlugin(original, pluginName)
|
||||||
|
|
||||||
|
assert.equal(result.added, false)
|
||||||
|
assert.equal(result.content, original)
|
||||||
|
})
|
||||||
|
|
||||||
test("removes only the requested package plugin", () => {
|
test("removes only the requested package plugin", () => {
|
||||||
const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2)
|
const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2)
|
||||||
const result = removePlugin(original, pluginName)
|
const result = removePlugin(original, pluginName)
|
||||||
@@ -36,6 +44,14 @@ test("removes only the requested package plugin", () => {
|
|||||||
assert.deepEqual(JSON.parse(result.content).plugins, ["other-plugin"])
|
assert.deepEqual(JSON.parse(result.content).plugins, ["other-plugin"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves an object-form plugin entry because it may contain user options", () => {
|
||||||
|
const original = JSON.stringify({ plugins: [{ package: pluginName, options: { memory: { enabled: true } } }] }, null, 2)
|
||||||
|
const result = removePlugin(original, pluginName)
|
||||||
|
|
||||||
|
assert.equal(result.removed, false)
|
||||||
|
assert.equal(result.content, original)
|
||||||
|
})
|
||||||
|
|
||||||
test("replaces a managed plugin entry while preserving JSONC comments", () => {
|
test("replaces a managed plugin entry while preserving JSONC comments", () => {
|
||||||
const original = `{
|
const original = `{
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { createMemoryStore } from "../dist/memory.js"
|
||||||
|
|
||||||
|
function createStorage() {
|
||||||
|
const values = new Map()
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
get: async (key) => values.get(key),
|
||||||
|
set: async (key, value) => { values.set(key, value) },
|
||||||
|
remove: async (key) => { values.delete(key) },
|
||||||
|
scan: async ({ prefix, after, limit }) => {
|
||||||
|
const keys = [...values.keys()].filter((key) => key.startsWith(prefix)).sort().filter((key) => after === undefined || key > after)
|
||||||
|
const selected = keys.slice(0, limit)
|
||||||
|
return {
|
||||||
|
entries: selected.map((key) => ({ key, value: values.get(key) })),
|
||||||
|
...(keys.length > selected.length && selected.length > 0 ? { next: selected.at(-1) } : {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("memory records expire and are removed when accessed", async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
let clock = new Date("2026-10-08T12:00:00.000Z")
|
||||||
|
const store = createMemoryStore({
|
||||||
|
storage,
|
||||||
|
projectID: "project-1",
|
||||||
|
projectCanonical: "/workspace/demo",
|
||||||
|
projectMemoryEnabled: false,
|
||||||
|
retentionDays: 1,
|
||||||
|
getSession: async (sessionID) => ({ projectID: "project-1", location: { directory: `/workspace/demo/${sessionID}` } }),
|
||||||
|
now: () => clock,
|
||||||
|
createID: () => "12345678-1234-4234-8234-123456789abc",
|
||||||
|
})
|
||||||
|
|
||||||
|
const record = await store.save({ sessionID: "session-1", scope: "session", content: "Keep this briefly" })
|
||||||
|
assert.equal(record.expiresAt, "2026-10-09T12:00:00.000Z")
|
||||||
|
clock = new Date("2026-10-09T12:00:00.000Z")
|
||||||
|
assert.equal(await store.get({ sessionID: "session-1", scope: "session", id: record.id }), undefined)
|
||||||
|
assert.equal(storage.values.size, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("memory writes reject sessions belonging to another project", async () => {
|
||||||
|
const store = createMemoryStore({
|
||||||
|
storage: createStorage(),
|
||||||
|
projectID: "project-1",
|
||||||
|
projectCanonical: "/workspace/demo",
|
||||||
|
projectMemoryEnabled: false,
|
||||||
|
retentionDays: 30,
|
||||||
|
getSession: async () => ({ projectID: "project-2", location: { directory: "/workspace/other" } }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await assert.rejects(store.save({ sessionID: "foreign", scope: "session", content: "remember" }), /current project/)
|
||||||
|
})
|
||||||
+76
-2
@@ -2,7 +2,7 @@ import assert from "node:assert/strict"
|
|||||||
import test from "node:test"
|
import test from "node:test"
|
||||||
import toolkit from "../dist/plugin.js"
|
import toolkit from "../dist/plugin.js"
|
||||||
|
|
||||||
function createContext() {
|
function createContext({ options = {}, projectID = "project-1", projectCanonical = "/workspace/demo", storage = new Map() } = {}) {
|
||||||
const registered = []
|
const registered = []
|
||||||
const vcs = {
|
const vcs = {
|
||||||
get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }),
|
get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }),
|
||||||
@@ -12,9 +12,30 @@ function createContext() {
|
|||||||
const context = {
|
const context = {
|
||||||
location: {
|
location: {
|
||||||
directory: "/workspace/demo",
|
directory: "/workspace/demo",
|
||||||
project: { id: "project-1", directory: "/workspace/demo", canonical: "/workspace/demo" },
|
project: { id: projectID, directory: "/workspace/demo", canonical: projectCanonical },
|
||||||
},
|
},
|
||||||
|
options,
|
||||||
vcs,
|
vcs,
|
||||||
|
session: {
|
||||||
|
get: async ({ sessionID }) => ({
|
||||||
|
id: sessionID,
|
||||||
|
projectID,
|
||||||
|
location: { directory: "/workspace/demo" },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
get: async (key) => storage.get(key),
|
||||||
|
set: async (key, value) => { storage.set(key, value) },
|
||||||
|
remove: async (key) => { storage.delete(key) },
|
||||||
|
scan: async ({ prefix, after, limit }) => {
|
||||||
|
const keys = [...storage.keys()].filter((key) => key.startsWith(prefix)).sort().filter((key) => after === undefined || key > after)
|
||||||
|
const selected = keys.slice(0, limit)
|
||||||
|
return {
|
||||||
|
entries: selected.map((key) => ({ key, value: storage.get(key) })),
|
||||||
|
...(keys.length > selected.length && selected.length > 0 ? { next: selected.at(-1) } : {}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
tool: {
|
tool: {
|
||||||
transform: async (register) => {
|
transform: async (register) => {
|
||||||
register({
|
register({
|
||||||
@@ -76,3 +97,56 @@ test("status tool reports VCS context without mutating it", async () => {
|
|||||||
assert.equal(vcs.get, originalGet)
|
assert.equal(vcs.get, originalGet)
|
||||||
assert.equal(vcs.status, originalStatus)
|
assert.equal(vcs.status, originalStatus)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("memory tools are disabled unless explicitly enabled", async () => {
|
||||||
|
const { context, registered } = createContext()
|
||||||
|
await toolkit.setup(context)
|
||||||
|
assert.deepEqual(registered.map((tool) => tool.name), ["project_status"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session memories can be saved, listed, retrieved, and deleted without crossing sessions", async () => {
|
||||||
|
const { context, registered } = createContext({ options: { memory: { enabled: true } } })
|
||||||
|
await toolkit.setup(context)
|
||||||
|
const tools = Object.fromEntries(registered.map((tool) => [tool.name, tool]))
|
||||||
|
const execution = (sessionID) => ({ sessionID, signal: new AbortController().signal })
|
||||||
|
|
||||||
|
const saved = JSON.parse((await tools.memory_save.execute({ content: "Use strict TypeScript." }, execution("session-a"))).content)
|
||||||
|
assert.equal(saved.scope, "session")
|
||||||
|
assert.equal(typeof saved.expiresAt, "string")
|
||||||
|
assert.deepEqual(JSON.parse((await tools.memory_list.execute({}, execution("session-a"))).content).map(({ id }) => id), [saved.id])
|
||||||
|
assert.deepEqual(JSON.parse((await tools.memory_list.execute({}, execution("session-b"))).content), [])
|
||||||
|
assert.match((await tools.memory_get.execute({ id: saved.id }, execution("session-a"))).content, /Use strict TypeScript/)
|
||||||
|
assert.equal((await tools.memory_delete.execute({ id: saved.id }, execution("session-a"))).content, "Memory deleted.")
|
||||||
|
assert.deepEqual(JSON.parse((await tools.memory_list.execute({}, execution("session-a"))).content), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("project memories require explicit configuration and are isolated by project", async () => {
|
||||||
|
const sharedStorage = new Map()
|
||||||
|
const enabled = createContext({ options: { memory: { enabled: true, project: true } }, storage: sharedStorage })
|
||||||
|
await toolkit.setup(enabled.context)
|
||||||
|
const tools = Object.fromEntries(enabled.registered.map((tool) => [tool.name, tool]))
|
||||||
|
const execution = (sessionID) => ({ sessionID, signal: new AbortController().signal })
|
||||||
|
const saved = JSON.parse((await tools.memory_save.execute({ content: "Shared project convention", scope: "project" }, execution("session-a"))).content)
|
||||||
|
assert.equal(saved.scope, "project")
|
||||||
|
assert.equal(JSON.parse((await tools.memory_list.execute({ scope: "project" }, execution("session-b"))).content).length, 1)
|
||||||
|
|
||||||
|
const other = createContext({
|
||||||
|
options: { memory: { enabled: true, project: true } },
|
||||||
|
projectID: "project-2",
|
||||||
|
projectCanonical: "/workspace/other",
|
||||||
|
storage: sharedStorage,
|
||||||
|
})
|
||||||
|
await toolkit.setup(other.context)
|
||||||
|
const otherTools = Object.fromEntries(other.registered.map((tool) => [tool.name, tool]))
|
||||||
|
assert.deepEqual(JSON.parse((await otherTools.memory_list.execute({ scope: "project" }, execution("session-c"))).content), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("project scope fails closed when project memory has not been opted into", async () => {
|
||||||
|
const { context, registered } = createContext({ options: { memory: { enabled: true } } })
|
||||||
|
await toolkit.setup(context)
|
||||||
|
const save = registered.find((tool) => tool.name === "memory_save")
|
||||||
|
await assert.rejects(
|
||||||
|
save.execute({ content: "Not enabled", scope: "project" }, { sessionID: "session-a", signal: new AbortController().signal }),
|
||||||
|
/Project memory is disabled/,
|
||||||
|
)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user