#!/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 [args] commands: root read search [folder] create [title] base-query [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 " cat "$(safe_path "$1")" ;; search) [[ $# -ge 1 ]] || die "usage: memory.sh search [folder]" search_memory "$1" "${2:-}" ;; create) [[ $# -ge 2 ]] || die "usage: memory.sh create [title]" create_note "$1" "$2" "${3:-$2}" ;; base-query) [[ $# -ge 1 ]] || die "usage: memory.sh base-query [format]" base_query "$1" "${2:-md}" ;; health) health_check ;; *) usage exit 1 ;; esac } main "$@"