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

View File

@@ -3,11 +3,26 @@
This directory contains helpers that automate:
- context aggregation
- canonical memory access
- standup generation
- manager update drafting
- Mattermost-ready message formatting
- historical Slack import
The project-agnostic memory interface lives in:
- `scripts/memory/`
Recommended commands:
```bash
bash scripts/memory/memory.sh health
bash scripts/memory/memory.sh search "PDIAP-15765"
bash scripts/memory/memory.sh create work-item pdiap-15999 "Example title"
```
This interface defaults to Markdown files under `vault/`, uses Obsidian CLI when useful and available, and falls back to direct file operations.
The default workspace Mattermost extractor now lives in:
- `scripts/mattermost/`

42
scripts/memory/README.md Normal file
View 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
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 "$@"

20
scripts/obsidian/cli.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/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}}"
if ! command -v obsidian >/dev/null 2>&1; then
echo "obsidian CLI is not installed or not in PATH" >&2
exit 127
fi
if [[ ! -d "$VAULT_DIR" ]]; then
echo "vault directory not found: $VAULT_DIR" >&2
exit 1
fi
cd "$VAULT_DIR"
exec obsidian "$@"

View File

@@ -4,7 +4,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VAULT_DIR="${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/vault}"
VAULT_DIR="${AIW_OBSIDIAN_VAULT_DIR:-${AIW_MEMORY_VAULT_DIR:-$WORKSPACE_ROOT/vault}}"
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
URI="$("$SCRIPT_DIR/uri.sh" daily "vault=$VAULT_NAME")"

View File

@@ -4,7 +4,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VAULT_DIR="${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/vault}"
VAULT_DIR="${AIW_OBSIDIAN_VAULT_DIR:-${AIW_MEMORY_VAULT_DIR:-$WORKSPACE_ROOT/vault}}"
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
if [[ $# -lt 1 ]]; then

View File

@@ -4,7 +4,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VAULT_DIR="${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/vault}"
VAULT_DIR="${AIW_OBSIDIAN_VAULT_DIR:-${AIW_MEMORY_VAULT_DIR:-$WORKSPACE_ROOT/vault}}"
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
if [[ $# -lt 1 ]]; then