feat: Obsidian integration via cli scripts
This commit is contained in:
42
scripts/memory/README.md
Normal file
42
scripts/memory/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Memory Scripts
|
||||
|
||||
This directory exposes a project-agnostic interface for the canonical memory vault.
|
||||
|
||||
The current implementation uses Markdown files under `vault/` and can optionally delegate to the Obsidian CLI when it is available. The agent should depend on this memory interface, not on Obsidian-specific behavior, so the backing tool can be replaced later.
|
||||
|
||||
## Backend Model
|
||||
|
||||
- `AIW_MEMORY_VAULT_DIR` points to the canonical Markdown memory directory.
|
||||
- `AIW_MEMORY_BACKEND` defaults to `auto`.
|
||||
- `auto` uses Obsidian CLI when it is useful and available, then falls back to direct Markdown operations.
|
||||
- `files` forces direct Markdown operations.
|
||||
- `obsidian` requires the Obsidian CLI for supported operations.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bash scripts/memory/memory.sh root
|
||||
bash scripts/memory/memory.sh read 01-current/current-work.md
|
||||
bash scripts/memory/memory.sh search "PDIAP-15765"
|
||||
bash scripts/memory/memory.sh create work-item pdiap-15999 "Example title"
|
||||
bash scripts/memory/memory.sh base-query work-items
|
||||
bash scripts/memory/memory.sh health
|
||||
```
|
||||
|
||||
## Note Creation
|
||||
|
||||
`create` maps note types to canonical folders and templates:
|
||||
|
||||
- `daily` -> `06-daily/`
|
||||
- `work-item` -> `02-work-items/`
|
||||
- `person` -> `04-people/`
|
||||
- `decision` -> `05-decisions/`
|
||||
- `system` -> `03-context/systems/`
|
||||
- `workstream` -> `03-context/workstreams/`
|
||||
- `meeting-note` -> `06-daily/`
|
||||
|
||||
The template is resolved from `09-templates/<type>.md`. When Obsidian CLI is available, the script uses `obsidian create path=... template=...`. Otherwise it creates the file directly from the template and resolves the basic `{{title}}`, `{{date}}`, and `{{time}}` variables.
|
||||
|
||||
## Agent Rule
|
||||
|
||||
Use these scripts for vault-level operations such as creating notes, querying Bases, validating navigation health, and searching memory. For precise content edits, agents should still edit Markdown files directly so diffs remain auditable.
|
||||
335
scripts/memory/memory.sh
Executable file
335
scripts/memory/memory.sh
Executable file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/vault}}"
|
||||
BACKEND="${AIW_MEMORY_BACKEND:-auto}"
|
||||
OBSIDIAN_CLI="$WORKSPACE_ROOT/scripts/obsidian/cli.sh"
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: memory.sh <command> [args]
|
||||
|
||||
commands:
|
||||
root
|
||||
read <vault-relative-path>
|
||||
search <query> [folder]
|
||||
create <type> <slug> [title]
|
||||
base-query <base-name> [format]
|
||||
health
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ensure_vault() {
|
||||
[[ -d "$VAULT_DIR" ]] || die "vault directory not found: $VAULT_DIR"
|
||||
}
|
||||
|
||||
has_obsidian() {
|
||||
[[ "$BACKEND" != "files" ]] && command -v obsidian >/dev/null 2>&1 && [[ -x "$OBSIDIAN_CLI" ]]
|
||||
}
|
||||
|
||||
require_obsidian_if_forced() {
|
||||
if [[ "$BACKEND" == "obsidian" ]] && ! has_obsidian; then
|
||||
die "AIW_MEMORY_BACKEND=obsidian but Obsidian CLI is unavailable"
|
||||
fi
|
||||
}
|
||||
|
||||
safe_path() {
|
||||
local rel="$1"
|
||||
[[ "$rel" != /* ]] || die "path must be vault-relative: $rel"
|
||||
[[ "$rel" != *".."* ]] || die "path must not contain '..': $rel"
|
||||
printf '%s/%s\n' "$VAULT_DIR" "$rel"
|
||||
}
|
||||
|
||||
template_name_for_type() {
|
||||
case "$1" in
|
||||
daily) echo "daily" ;;
|
||||
work-item) echo "work-item" ;;
|
||||
person) echo "person" ;;
|
||||
decision) echo "decision" ;;
|
||||
system) echo "system" ;;
|
||||
workstream) echo "workstream" ;;
|
||||
meeting-note) echo "meeting-note" ;;
|
||||
*) die "unsupported note type: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
folder_for_type() {
|
||||
case "$1" in
|
||||
daily) echo "06-daily" ;;
|
||||
work-item) echo "02-work-items" ;;
|
||||
person) echo "04-people" ;;
|
||||
decision) echo "05-decisions" ;;
|
||||
system) echo "03-context/systems" ;;
|
||||
workstream) echo "03-context/workstreams" ;;
|
||||
meeting-note) echo "06-daily" ;;
|
||||
*) die "unsupported note type: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
note_path_for_type() {
|
||||
local type="$1"
|
||||
local slug="$2"
|
||||
local folder
|
||||
folder="$(folder_for_type "$type")"
|
||||
case "$slug" in
|
||||
*.md) echo "$folder/$slug" ;;
|
||||
*) echo "$folder/$slug.md" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
render_template() {
|
||||
local template="$1"
|
||||
local title="$2"
|
||||
local slug="$3"
|
||||
python3 - "$template" "$title" "$slug" <<'PY'
|
||||
import datetime
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
template = pathlib.Path(sys.argv[1])
|
||||
title = sys.argv[2]
|
||||
slug = sys.argv[3]
|
||||
now = datetime.datetime.now()
|
||||
ticket = slug.removesuffix(".md").upper()
|
||||
ticket = re.sub(r"^([A-Z]+)-?(\d+)$", r"\1-\2", ticket)
|
||||
content = template.read_text()
|
||||
content = content.replace("{{title}}", title)
|
||||
content = content.replace("{{slug}}", slug.removesuffix(".md"))
|
||||
content = content.replace("{{ticket}}", ticket)
|
||||
content = content.replace("{{date}}", now.strftime("%Y-%m-%d"))
|
||||
content = content.replace("{{date:YYYY-MM-DD}}", now.strftime("%Y-%m-%d"))
|
||||
content = content.replace("{{time}}", now.strftime("%H:%M"))
|
||||
print(content, end="")
|
||||
PY
|
||||
}
|
||||
|
||||
postprocess_note() {
|
||||
local rel="$1"
|
||||
local title="$2"
|
||||
local slug="$3"
|
||||
local target
|
||||
target="$(safe_path "$rel")"
|
||||
[[ -f "$target" ]] || return 0
|
||||
python3 - "$target" "$title" "$slug" <<'PY'
|
||||
import datetime
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = pathlib.Path(sys.argv[1])
|
||||
title = sys.argv[2]
|
||||
slug = sys.argv[3].removesuffix(".md")
|
||||
ticket = re.sub(r"^([A-Z]+)-?(\d+)$", r"\1-\2", slug.upper())
|
||||
today = datetime.datetime.now().strftime("%Y-%m-%d")
|
||||
now = datetime.datetime.now().strftime("%H:%M")
|
||||
|
||||
content = path.read_text()
|
||||
content = content.replace("{{title}}", title)
|
||||
content = content.replace("{{slug}}", slug)
|
||||
content = content.replace("{{ticket}}", ticket)
|
||||
content = content.replace("{{date}}", today)
|
||||
content = content.replace("{{date:YYYY-MM-DD}}", today)
|
||||
content = content.replace("{{time}}", now)
|
||||
|
||||
lines = content.splitlines()
|
||||
if lines[:1] == ["---"]:
|
||||
try:
|
||||
end = lines.index("---", 1)
|
||||
except ValueError:
|
||||
end = -1
|
||||
if end > 0:
|
||||
frontmatter = lines[1:end]
|
||||
body = lines[end + 1 :]
|
||||
for i, line in enumerate(frontmatter):
|
||||
if line.startswith("title:"):
|
||||
frontmatter[i] = f"title: {title}"
|
||||
elif line.startswith("ticket:"):
|
||||
frontmatter[i] = f"ticket: {ticket}"
|
||||
elif line.startswith("date:"):
|
||||
frontmatter[i] = f"date: {today}"
|
||||
elif line.startswith("updated:"):
|
||||
frontmatter[i] = f"updated: {today}"
|
||||
lines = ["---", *frontmatter, "---", *body]
|
||||
content = "\n".join(lines) + "\n"
|
||||
|
||||
path.write_text(content)
|
||||
PY
|
||||
}
|
||||
|
||||
create_direct() {
|
||||
local type="$1"
|
||||
local slug="$2"
|
||||
local title="${3:-$slug}"
|
||||
local rel target template_name template
|
||||
rel="$(note_path_for_type "$type" "$slug")"
|
||||
target="$(safe_path "$rel")"
|
||||
template_name="$(template_name_for_type "$type")"
|
||||
template="$VAULT_DIR/09-templates/$template_name.md"
|
||||
|
||||
[[ -e "$target" ]] && die "note already exists: $rel"
|
||||
[[ -f "$template" ]] || die "template not found: 09-templates/$template_name.md"
|
||||
mkdir -p "$(dirname "$target")"
|
||||
render_template "$template" "$title" "$slug" > "$target"
|
||||
postprocess_note "$rel" "$title" "$slug"
|
||||
echo "$rel"
|
||||
}
|
||||
|
||||
create_note() {
|
||||
local type="$1"
|
||||
local slug="$2"
|
||||
local title="${3:-$slug}"
|
||||
local rel template_name
|
||||
rel="$(note_path_for_type "$type" "$slug")"
|
||||
template_name="$(template_name_for_type "$type")"
|
||||
|
||||
require_obsidian_if_forced
|
||||
if has_obsidian; then
|
||||
if "$OBSIDIAN_CLI" create "path=$rel" "template=$template_name" >/dev/null 2>&1; then
|
||||
postprocess_note "$rel" "$title" "$slug"
|
||||
echo "$rel"
|
||||
return 0
|
||||
fi
|
||||
[[ "$BACKEND" == "obsidian" ]] && die "obsidian create failed for $rel"
|
||||
fi
|
||||
|
||||
create_direct "$type" "$slug" "$title"
|
||||
}
|
||||
|
||||
search_memory() {
|
||||
local query="$1"
|
||||
local folder="${2:-}"
|
||||
|
||||
require_obsidian_if_forced
|
||||
if has_obsidian; then
|
||||
if [[ -n "$folder" ]]; then
|
||||
if "$OBSIDIAN_CLI" search:context "query=$query" "path=$folder" "limit=50"; then
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
if "$OBSIDIAN_CLI" search:context "query=$query" "limit=50"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
[[ "$BACKEND" == "obsidian" ]] && die "obsidian search failed"
|
||||
fi
|
||||
|
||||
if [[ -n "$folder" ]]; then
|
||||
rg -n -- "$query" "$VAULT_DIR/$folder"
|
||||
else
|
||||
rg -n -- "$query" "$VAULT_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
base_query() {
|
||||
local base="$1"
|
||||
local format="${2:-md}"
|
||||
local base_path="08-bases/${base%.base}.base"
|
||||
local type_name="${base%.base}"
|
||||
|
||||
require_obsidian_if_forced
|
||||
if has_obsidian; then
|
||||
if "$OBSIDIAN_CLI" base:query "path=$base_path" "format=$format"; then
|
||||
return 0
|
||||
fi
|
||||
[[ "$BACKEND" == "obsidian" ]] && die "obsidian base query failed for $base_path"
|
||||
fi
|
||||
|
||||
case "$type_name" in
|
||||
work-items) rg -l '^type: work-item$' "$VAULT_DIR/02-work-items" ;;
|
||||
people) rg -l '^type: person$' "$VAULT_DIR/04-people" ;;
|
||||
decisions) rg -l '^type: decision$' "$VAULT_DIR/05-decisions" ;;
|
||||
daily) rg -l '^type: daily$' "$VAULT_DIR/06-daily" ;;
|
||||
systems) rg -l '^type: system$' "$VAULT_DIR/03-context/systems" ;;
|
||||
workstreams) rg -l '^type: workstream$' "$VAULT_DIR/03-context/workstreams" ;;
|
||||
*) die "unsupported base fallback: $base" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
health_check() {
|
||||
local failed=0
|
||||
local required=(
|
||||
"00-start/start-here.md"
|
||||
"01-current/current-work.md"
|
||||
"01-current/work-items.md"
|
||||
"02-work-items/index.md"
|
||||
"03-context/project.md"
|
||||
"04-people/index.md"
|
||||
"07-maps/index.md"
|
||||
"08-bases/work-items.base"
|
||||
"09-templates/work-item.md"
|
||||
)
|
||||
|
||||
ensure_vault
|
||||
|
||||
for rel in "${required[@]}"; do
|
||||
if [[ ! -e "$VAULT_DIR/$rel" ]]; then
|
||||
echo "missing: $rel"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -d "$WORKSPACE_ROOT/.obsidian" ]]; then
|
||||
echo "unexpected root Obsidian config: .obsidian"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if has_obsidian; then
|
||||
"$OBSIDIAN_CLI" unresolved total 2>/dev/null | sed 's/^/unresolved-links: /' || true
|
||||
"$OBSIDIAN_CLI" orphans total 2>/dev/null | sed 's/^/orphans: /' || true
|
||||
"$OBSIDIAN_CLI" tags total 2>/dev/null | sed 's/^/tags: /' || true
|
||||
else
|
||||
echo "obsidian-cli: unavailable; file checks only"
|
||||
fi
|
||||
|
||||
if [[ "$failed" -eq 0 ]]; then
|
||||
echo "memory health ok"
|
||||
fi
|
||||
exit "$failed"
|
||||
}
|
||||
|
||||
main() {
|
||||
ensure_vault
|
||||
local command="${1:-}"
|
||||
[[ -n "$command" ]] || { usage; exit 1; }
|
||||
shift || true
|
||||
|
||||
case "$command" in
|
||||
root)
|
||||
echo "$VAULT_DIR"
|
||||
;;
|
||||
read)
|
||||
[[ $# -eq 1 ]] || die "usage: memory.sh read <vault-relative-path>"
|
||||
cat "$(safe_path "$1")"
|
||||
;;
|
||||
search)
|
||||
[[ $# -ge 1 ]] || die "usage: memory.sh search <query> [folder]"
|
||||
search_memory "$1" "${2:-}"
|
||||
;;
|
||||
create)
|
||||
[[ $# -ge 2 ]] || die "usage: memory.sh create <type> <slug> [title]"
|
||||
create_note "$1" "$2" "${3:-$2}"
|
||||
;;
|
||||
base-query)
|
||||
[[ $# -ge 1 ]] || die "usage: memory.sh base-query <base-name> [format]"
|
||||
base_query "$1" "${2:-md}"
|
||||
;;
|
||||
health)
|
||||
health_check
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user