Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,7 @@
"name": "Amp",
"category": "coding",
"type": "plugin",
"version": "0.1.0",
"version": "0.1.1",
"directory": "nowledge-mem-amp-plugin",
"transport": "cli+http",
"capabilities": {
Expand Down
11 changes: 11 additions & 0 deletions nowledge-mem-amp-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## [0.1.1] - 2026-08-10

### Fixed

- Restored the Amp-discoverable root plugin entry at `plugins/nowledge-mem.ts`
during install and update. Existing `0.1.0` installs may have copied the
bundle without the root entry, so Amp could miss the plugin.
- Hardened install rollback so failed updates preserve the previous entry,
bundle, and skill, and failed first installs do not leave a partial Amp
plugin behind.

## [0.1.0] - 2026-08-08

### Added
Expand Down
2 changes: 1 addition & 1 deletion nowledge-mem-amp-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ For multi-agent setups, set `NMEM_AGENT_ID=<agent-slug>` per spawned Amp worker.

- **nmem not found.** Install with `pip install nmem-cli`, or on Arch Linux `yay -S nmem-cli` / `paru -S nmem-cli`, then run `nmem status` to verify.
- **Server not responding.** Start the Nowledge Mem desktop app, or check `nmem status` for diagnostics.
- **Plugin not loading.** Re-run `./scripts/install.sh`, confirm the files exist under `~/.config/amp/plugins/nowledge-mem/`, and restart Amp.
- **Plugin not loading.** Re-run `bash nowledge-mem-amp-plugin/scripts/install.sh` from the `community` repository root, confirm the root entry `${XDG_CONFIG_HOME:-$HOME/.config}/amp/plugins/nowledge-mem.ts` and the bundle beside it exist, and restart Amp.

## Links

Expand Down
2 changes: 1 addition & 1 deletion nowledge-mem-amp-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "amp-nowledge-mem",
"version": "0.1.0",
"version": "0.1.1",
"description": "Nowledge Mem plugin for Amp. Cross-tool knowledge with agent-end thread capture, Context Bundle, search, save, and handoffs.",
"type": "module",
"main": "src/index.ts",
Expand Down
104 changes: 89 additions & 15 deletions nowledge-mem-amp-plugin/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@
#
# Installs the Nowledge Mem plugin and skill into Amp's per-user directories.
#
# entry -> ${XDG_CONFIG_HOME:-~/.config}/amp/plugins/nowledge-mem.ts
# plugin -> ${XDG_CONFIG_HOME:-~/.config}/amp/plugins/nowledge-mem/
# skill -> ${XDG_CONFIG_HOME:-~/.config}/amp/skills/nowledge-mem/
#
# Re-running the script updates an existing installation. Restart Amp after
# installing or updating so the plugin and skill are picked up.
# Amp discovers a single-file plugin from a root .ts/.js entry inside the
# plugins directory; the entry re-exports the bundle's default plugin so a
# bare bundle directory alone is not loadable. Re-running the script updates
# an existing installation. Restart Amp after installing or updating so the
# plugin and skill are picked up.

set -euo pipefail

Expand All @@ -15,6 +19,7 @@ AMP_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/amp"
PLUGINS_DIR="$AMP_CONFIG_DIR/plugins"
SKILLS_DIR="$AMP_CONFIG_DIR/skills"
PLUGIN_DEST="$PLUGINS_DIR/$PLUGIN_NAME"
PLUGIN_ENTRY_DEST="$PLUGINS_DIR/$PLUGIN_NAME.ts"
SKILL_DEST="$SKILLS_DIR/$PLUGIN_NAME"

# Resolve the directory this script lives in, so the command works regardless
Expand All @@ -33,50 +38,111 @@ trap cleanup EXIT

STAGED_PLUGIN="$STAGING_DIR/plugin"
STAGED_SKILL="$STAGING_DIR/skill"
STAGED_ENTRY="$STAGING_DIR/entry.ts"
BACKUP_PLUGIN="$STAGING_DIR/old-plugin"
BACKUP_SKILL="$STAGING_DIR/old-skill"
BACKUP_ENTRY="$STAGING_DIR/old-entry.ts"

# Track which backups were created so restore_previous only removes/restores
# artifacts that have a valid backup. A failed backup leaves the live artifact
# in place (a failed mv keeps its source); the matching flag stays 0, so the
# live artifact is never deleted without a backup to restore from.
ENTRY_BACKED_UP=0
BUNDLE_BACKED_UP=0
SKILL_BACKED_UP=0
ENTRY_INSTALLED=0
BUNDLE_INSTALLED=0
SKILL_INSTALLED=0

# Restore the previously active entry, bundle, and skill after a failed
# replacement. Only destinations whose backup succeeded are removed, and only
# those backups are restored. Fresh artifacts that were installed before a
# later step failed are removed so failed first installs do not leave partial
# Amp-visible state behind.
restore_previous() {
rm -rf "$PLUGIN_DEST" "$SKILL_DEST"
if [ -e "$BACKUP_PLUGIN" ]; then
if [ "$ENTRY_BACKED_UP" -eq 1 ]; then
rm -rf "$PLUGIN_ENTRY_DEST"
mv "$BACKUP_ENTRY" "$PLUGIN_ENTRY_DEST"
elif [ "$ENTRY_INSTALLED" -eq 1 ]; then
rm -rf "$PLUGIN_ENTRY_DEST"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if [ "$BUNDLE_BACKED_UP" -eq 1 ]; then
rm -rf "$PLUGIN_DEST"
mv "$BACKUP_PLUGIN" "$PLUGIN_DEST"
elif [ "$BUNDLE_INSTALLED" -eq 1 ]; then
rm -rf "$PLUGIN_DEST"
fi
if [ -e "$BACKUP_SKILL" ]; then
if [ "$SKILL_BACKED_UP" -eq 1 ]; then
rm -rf "$SKILL_DEST"
mv "$BACKUP_SKILL" "$SKILL_DEST"
elif [ "$SKILL_INSTALLED" -eq 1 ]; then
rm -rf "$SKILL_DEST"
fi
}

install_staged() {
if [ -e "$PLUGIN_DEST" ] && ! mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then
return 1
if [ -e "$PLUGIN_ENTRY_DEST" ]; then
if mv "$PLUGIN_ENTRY_DEST" "$BACKUP_ENTRY"; then
ENTRY_BACKED_UP=1
else
return 1
fi
fi
if [ -d "$STAGED_SKILL" ] && [ -e "$SKILL_DEST" ] && ! mv "$SKILL_DEST" "$BACKUP_SKILL"; then
restore_previous
return 1
if [ -e "$PLUGIN_DEST" ]; then
if mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then
BUNDLE_BACKED_UP=1
else
restore_previous
return 1
fi
fi
if [ -d "$STAGED_SKILL" ] && [ -e "$SKILL_DEST" ]; then
if mv "$SKILL_DEST" "$BACKUP_SKILL"; then
SKILL_BACKED_UP=1
else
restore_previous
return 1
fi
fi
if ! mv "$STAGED_PLUGIN" "$PLUGIN_DEST"; then
if mv "$STAGED_PLUGIN" "$PLUGIN_DEST"; then
BUNDLE_INSTALLED=1
else
restore_previous
return 1
fi
if [ -d "$STAGED_SKILL" ] && ! mv "$STAGED_SKILL" "$SKILL_DEST"; then
if [ -d "$STAGED_SKILL" ]; then
if mv "$STAGED_SKILL" "$SKILL_DEST"; then
SKILL_INSTALLED=1
else
restore_previous
return 1
fi
fi
if mv "$STAGED_ENTRY" "$PLUGIN_ENTRY_DEST"; then
ENTRY_INSTALLED=1
else
restore_previous
return 1
fi
rm -rf "$BACKUP_PLUGIN" "$BACKUP_SKILL"
rm -rf "$BACKUP_PLUGIN" "$BACKUP_SKILL" "$BACKUP_ENTRY"
}

echo "Installing Nowledge Mem for Amp"
echo " source: $PLUGIN_SRC"
echo " entry: $PLUGIN_ENTRY_DEST"
echo " plugin: $PLUGIN_DEST"
echo " skill: $SKILL_DEST"

# Stage the plugin and skill before touching the active installation.
# Stage the plugin, skill, and root entry before touching the active
# installation. The entry re-exports the bundle's default plugin so Amp
# discovers it as a single-file plugin (see the header comment).
cp -R "$PLUGIN_SRC" "$STAGED_PLUGIN"
if [ -d "$STAGED_PLUGIN/skills/$PLUGIN_NAME" ]; then
cp -R "$STAGED_PLUGIN/skills/$PLUGIN_NAME" "$STAGED_SKILL"
fi
printf 'export { default } from "./nowledge-mem/src/index.ts"\n' > "$STAGED_ENTRY"

# Validate the staged plugin entrypoint exists before replacement.
# Validate the staged bundle, skill, and entry before replacement.
if [ ! -f "$STAGED_PLUGIN/src/index.ts" ]; then
echo "Error: staged plugin is missing src/index.ts" >&2
exit 1
Expand All @@ -85,6 +151,14 @@ if [ ! -f "$STAGED_PLUGIN/skills/$PLUGIN_NAME/SKILL.md" ]; then
echo "Error: staged plugin is missing skills/$PLUGIN_NAME/SKILL.md" >&2
exit 1
fi
if [ ! -f "$STAGED_ENTRY" ]; then
echo "Error: staged root entry was not written" >&2
exit 1
fi
if ! grep -q 'export { default } from "./nowledge-mem/src/index.ts"' "$STAGED_ENTRY"; then
echo "Error: staged root entry does not re-export the bundle default" >&2
exit 1
fi

if ! install_staged; then
echo "Error: could not replace the active installation; previous files were restored." >&2
Expand Down
162 changes: 161 additions & 1 deletion tests/plugin_e2e/test_key_plugins_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -1626,7 +1626,7 @@ def test_amp_plugin_static_contract_is_self_contained():

# Package manifest and registry entry agree on the basics.
assert pkg["name"] == "amp-nowledge-mem"
assert pkg["version"] == "0.1.0"
assert pkg["version"] == "0.1.1"
assert pkg["type"] == "module"
assert pkg["main"] == "src/index.ts"
assert "@ampcode/plugin" in pkg["peerDependencies"]
Expand Down Expand Up @@ -1688,6 +1688,15 @@ def test_amp_plugin_static_contract_is_self_contained():
assert "STAGED_PLUGIN=" in install_sh
assert "install_staged" in install_sh
assert 'skills/$PLUGIN_NAME/SKILL.md' in install_sh
# The script installs an Amp-discoverable root plugin entry that re-exports
# the bundle default, alongside the bundle dir and skill.
assert "PLUGIN_ENTRY_DEST=" in install_sh
assert "STAGED_ENTRY=" in install_sh
assert "BACKUP_ENTRY=" in install_sh
assert "ENTRY_INSTALLED=" in install_sh
assert "BUNDLE_INSTALLED=" in install_sh
assert "SKILL_INSTALLED=" in install_sh
assert 'export { default } from "./nowledge-mem/src/index.ts"' in install_sh

# No raw secrets are checked into the package.
assert "NMEM_API_KEY" in readme
Expand Down Expand Up @@ -2677,3 +2686,154 @@ def test_opencode_live_tool_thread_capture(e2e_context: E2EContext, tmp_path: Pa
space=e2e_context.space,
env=env,
)


def _run_amp_install(xdg_home: Path, *, script: Path | None = None) -> subprocess.CompletedProcess[str]:
"""Run the Amp plugin install script isolated under an XDG_CONFIG_HOME root.

Returns the CompletedProcess regardless of exit code so callers can assert
on success or failure. The script resolves its own source dir, so `script`
defaults to the in-tree install.sh.
"""
install_script = script if script is not None else AMP_PLUGIN / "scripts" / "install.sh"
return subprocess.run(
["bash", str(install_script)],
env={**os.environ, "XDG_CONFIG_HOME": str(xdg_home)},
text=True,
capture_output=True,
timeout=60,
)


def _assert_amp_artifacts(xdg_home: Path) -> None:
plugins_dir = xdg_home / "amp" / "plugins"
entry = plugins_dir / "nowledge-mem.ts"
bundle_index = plugins_dir / "nowledge-mem" / "src" / "index.ts"
skill = xdg_home / "amp" / "skills" / "nowledge-mem" / "SKILL.md"
assert entry.is_file(), f"root plugin entry missing: {entry}"
assert (
'export { default } from "./nowledge-mem/src/index.ts"' in entry.read_text(encoding="utf-8")
), "root entry does not re-export the bundle default"
# The entry's import target must resolve on disk, otherwise Amp cannot load it.
assert bundle_index.is_file(), f"entry target missing: {bundle_index}"
assert skill.is_file(), f"skill missing: {skill}"


def test_amp_install_script_creates_root_entry_bundle_and_skill(tmp_path: Path):
"""First install writes the Amp-discoverable root entry, the bundle it
re-exports, and the skill, all under the resolved XDG config directory."""
result = _run_amp_install(tmp_path)
assert result.returncode == 0, result.stderr
_assert_amp_artifacts(tmp_path)


def test_amp_install_script_is_idempotent(tmp_path: Path):
"""Re-running the script is a safe atomic update: all artifacts survive and
the root entry content is stable across runs."""
first = _run_amp_install(tmp_path)
assert first.returncode == 0, first.stderr
entry = tmp_path / "amp" / "plugins" / "nowledge-mem.ts"
bundle_index = tmp_path / "amp" / "plugins" / "nowledge-mem" / "src" / "index.ts"
entry_before = entry.read_text(encoding="utf-8")
bundle_before = bundle_index.read_text(encoding="utf-8")

second = _run_amp_install(tmp_path)
assert second.returncode == 0, second.stderr
_assert_amp_artifacts(tmp_path)
assert entry.read_text(encoding="utf-8") == entry_before
assert bundle_index.read_text(encoding="utf-8") == bundle_before


def test_amp_install_script_keeps_prior_install_when_preflight_fails(tmp_path: Path):
"""A failed pre-flight check must not disturb a prior valid installation:
the script stages everything before touching the active install, so a
missing source file fails fast and leaves the live entry/bundle/skill
intact."""
# Establish a known-good installation first.
seed = _run_amp_install(tmp_path)
assert seed.returncode == 0, seed.stderr
entry = tmp_path / "amp" / "plugins" / "nowledge-mem.ts"
bundle_index = tmp_path / "amp" / "plugins" / "nowledge-mem" / "src" / "index.ts"
skill = tmp_path / "amp" / "skills" / "nowledge-mem" / "SKILL.md"
entry_before = entry.read_text(encoding="utf-8")
bundle_before = bundle_index.read_text(encoding="utf-8")
skill_before = skill.read_text(encoding="utf-8")

# Build a broken source tree (missing src/index.ts) and run its install
# script against the same config root; the staged-bundle pre-flight check
# must abort before the live install is touched.
broken_src = tmp_path / "broken-amp-plugin-src"
shutil.copytree(AMP_PLUGIN, broken_src)
(broken_src / "src" / "index.ts").unlink()
failed = _run_amp_install(tmp_path, script=broken_src / "scripts" / "install.sh")

assert failed.returncode != 0
assert "staged plugin is missing src/index.ts" in failed.stderr
# The prior installation is untouched.
_assert_amp_artifacts(tmp_path)
assert entry.read_text(encoding="utf-8") == entry_before
assert bundle_index.read_text(encoding="utf-8") == bundle_before
assert skill.read_text(encoding="utf-8") == skill_before


def test_amp_install_script_preserves_prior_install_when_backup_move_fails(tmp_path: Path):
"""A failed backup of the live bundle must not delete it. restore_previous
only removes/restores artifacts whose backup succeeded, so a failed bundle
backup leaves the bundle in place and restores the entry from its successful
backup."""
seed = _run_amp_install(tmp_path)
assert seed.returncode == 0, seed.stderr
entry = tmp_path / "amp" / "plugins" / "nowledge-mem.ts"
bundle_dir = tmp_path / "amp" / "plugins" / "nowledge-mem"
bundle_index = bundle_dir / "src" / "index.ts"
skill = tmp_path / "amp" / "skills" / "nowledge-mem" / "SKILL.md"
entry_before = entry.read_text(encoding="utf-8")
bundle_before = bundle_index.read_text(encoding="utf-8")
skill_before = skill.read_text(encoding="utf-8")

# Inject a deterministic bundle-backup failure after the entry backup has
# succeeded. chmod on the bundle dir is not portable here: POSIX rename
# checks the parent directory, not the moved directory's own write bit.
broken_src = tmp_path / "broken-backup-amp-plugin-src"
shutil.copytree(AMP_PLUGIN, broken_src)
install_script = broken_src / "scripts" / "install.sh"
script = install_script.read_text(encoding="utf-8")
script = script.replace(
'if mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then',
'if false && mv "$PLUGIN_DEST" "$BACKUP_PLUGIN"; then',
1,
)
install_script.write_text(script, encoding="utf-8")
failed = _run_amp_install(tmp_path, script=install_script)

assert failed.returncode != 0
assert "could not replace the active installation" in failed.stderr
# The prior installation is fully intact; the bundle was never deleted.
_assert_amp_artifacts(tmp_path)
assert entry.read_text(encoding="utf-8") == entry_before
assert bundle_index.read_text(encoding="utf-8") == bundle_before
assert skill.read_text(encoding="utf-8") == skill_before


def test_amp_install_script_removes_fresh_partial_install_when_entry_move_fails(tmp_path: Path):
"""If a first install fails after moving the bundle and skill but before
moving the root entry, rollback removes the new partial install. Amp should
not see a half-installed bundle without the discoverable entry."""
broken_src = tmp_path / "broken-entry-amp-plugin-src"
shutil.copytree(AMP_PLUGIN, broken_src)
install_script = broken_src / "scripts" / "install.sh"
script = install_script.read_text(encoding="utf-8")
script = script.replace(
'if mv "$STAGED_ENTRY" "$PLUGIN_ENTRY_DEST"; then',
'rm -f "$STAGED_ENTRY"\n if mv "$STAGED_ENTRY" "$PLUGIN_ENTRY_DEST"; then',
1,
)
install_script.write_text(script, encoding="utf-8")

failed = _run_amp_install(tmp_path, script=install_script)

assert failed.returncode != 0
assert "could not replace the active installation" in failed.stderr
assert not (tmp_path / "amp" / "plugins" / "nowledge-mem.ts").exists()
assert not (tmp_path / "amp" / "plugins" / "nowledge-mem").exists()
assert not (tmp_path / "amp" / "skills" / "nowledge-mem").exists()
Loading