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.
|
||||
- Keep state scoped by project/session. Do not introduce a mutable module-level
|
||||
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
|
||||
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
|
||||
`prepack` build hook and therefore writes generated output.
|
||||
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
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
|
||||
|
||||
### Distribution
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
- [ ] Provider-backed workflows (agent delegation and actual skill invocation)
|
||||
have not been tested; they require configured credentials and explicit
|
||||
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
|
||||
uninstall are supported, while global installation remains out of scope.
|
||||
- [ ] 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.
|
||||
- Plugin tool: `toolkit_project_status`, implemented against OpenCode's V2 VCS
|
||||
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
|
||||
hashes, collision checks, and protection for locally modified files.
|
||||
|
||||
Provider/model selection remains the user's OpenCode configuration. The package
|
||||
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
|
||||
|
||||
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
|
||||
smoke-tested with OpenCode 2.0.24 and a local mock OpenAI-compatible endpoint.
|
||||
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
|
||||
|
||||
|
||||
@@ -26,3 +26,16 @@
|
||||
- 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
|
||||
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.
|
||||
- [x] Perform and record a pre-release manual security review of installer paths,
|
||||
file ownership, configuration edits, and failure limitations.
|
||||
- [ ] Publish the `0.1.0-rc.1` tarball as a repository pre-release and verify the
|
||||
direct installation URL from a clean project.
|
||||
- [x] Publish the `0.1.0-rc.1` tarball and SHA-256 as a repository pre-release;
|
||||
install from its public URL in a clean project and verify the checksum.
|
||||
- [ ] Add provider-backed verification after confirming credentials and model
|
||||
usage budget with the user.
|
||||
- [ ] Decide whether to publish the package to npm; repository pre-releases do
|
||||
@@ -34,6 +34,10 @@
|
||||
|
||||
## 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.
|
||||
- [ ] Evaluate global/XDG installation only if project-local usage proves
|
||||
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
|
||||
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,
|
||||
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
|
||||
found no release blocker for trusted project-local use; filesystem and
|
||||
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.
|
||||
The `0.1.0-rc.1` tarball and repository release still need publication and a
|
||||
clean-project install check.
|
||||
Typecheck, all 16 tests, coverage (87.4% lines), and package build passed. The
|
||||
`v0.1.0-rc.1` pre-release and SHA-256 sidecar are published. A clean project
|
||||
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
|
||||
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.
|
||||
- Prefer V2 public APIs over assumptions about OpenCode's storage layout.
|
||||
- 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
|
||||
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
|
||||
|
||||
|
||||
@@ -42,3 +42,23 @@ files, symlinked install directories, and a tampered manifest.
|
||||
branches.
|
||||
- `npm pack --dry-run` — passed; included runtime files, selected content,
|
||||
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 { createMemoryStore, MEMORY_LIMITS, type MemoryScope } from "./memory.js"
|
||||
|
||||
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({
|
||||
id: TOOLKIT_PLUGIN_ID,
|
||||
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) => {
|
||||
editor.namespace({
|
||||
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)
|
||||
})
|
||||
|
||||
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", () => {
|
||||
const original = JSON.stringify({ plugins: ["other-plugin", pluginName] }, null, 2)
|
||||
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"])
|
||||
})
|
||||
|
||||
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", () => {
|
||||
const original = `{
|
||||
"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 toolkit from "../dist/plugin.js"
|
||||
|
||||
function createContext() {
|
||||
function createContext({ options = {}, projectID = "project-1", projectCanonical = "/workspace/demo", storage = new Map() } = {}) {
|
||||
const registered = []
|
||||
const vcs = {
|
||||
get: async () => ({ branch: { current: "feature/toolkit", default: "main" } }),
|
||||
@@ -12,9 +12,30 @@ function createContext() {
|
||||
const context = {
|
||||
location: {
|
||||
directory: "/workspace/demo",
|
||||
project: { id: "project-1", directory: "/workspace/demo", canonical: "/workspace/demo" },
|
||||
project: { id: projectID, directory: "/workspace/demo", canonical: projectCanonical },
|
||||
},
|
||||
options,
|
||||
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: {
|
||||
transform: async (register) => {
|
||||
register({
|
||||
@@ -76,3 +97,56 @@ test("status tool reports VCS context without mutating it", async () => {
|
||||
assert.equal(vcs.get, originalGet)
|
||||
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