feat: Obsidian integration via cli scripts

This commit is contained in:
2026-04-17 08:05:23 -06:00
parent 902e11c7d4
commit a2b667f497
35 changed files with 715 additions and 34 deletions

335
scripts/memory/memory.sh Executable file
View 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 "$@"