Add Ollama provider, portable memory, cultivation digest, refugee importer, GLM-OCR spike

- P0: unified soul binary with engram_node_full fix, read-back-verify, search fix
- P0: move API keys from plaintext plists to macOS Keychain
- P0: fix MCP backend URL (port 8742 → 7770)
- P1.6: memory-export/import scripts (AES-256-CBC, versioned .neuronmem format)
- P1.7: nightly cultivation digest with sharpness metric (launchd at 23:55)
- P2.10: Ollama provider in agentic loop (SOUL_LLM_PROVIDER=ollama)
- P3.12: refugee importer for ChatGPT/Screenpipe/generic formats
- P3.13: GLM-OCR spike — SHIP IT (mlx-vlm, 1.59GB, photo-to-memory.sh)
This commit is contained in:
2026-06-27 11:46:30 -05:00
parent d4609c7baa
commit dcc0bf550a
22 changed files with 1623 additions and 24118 deletions
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env bash
# cultivation-digest.sh — Neuron daily cultivation digest
# Reads ~/.neuron/engram/snapshot.json and produces a sharpness report.
# Writes to ~/.neuron/digests/YYYY-MM-DD.txt and appends to sharpness.json.
set -euo pipefail
SNAPSHOT="$HOME/.neuron/engram/snapshot.json"
DIGESTS_DIR="$HOME/.neuron/digests"
DATE=$(date +%Y-%m-%d)
DIGEST_FILE="$DIGESTS_DIR/$DATE.txt"
SHARPNESS_FILE="$DIGESTS_DIR/sharpness.json"
mkdir -p "$DIGESTS_DIR"
if [[ ! -f "$SNAPSHOT" ]]; then
echo "ERROR: snapshot not found at $SNAPSHOT" >&2
exit 1
fi
# Cutoff: now minus 24 hours in milliseconds
NOW_MS=$(( $(date +%s) * 1000 ))
CUTOFF_MS=$(( NOW_MS - 86400000 ))
# ---------------------------------------------------------------------------
# Compute all metrics via a single jq pass (avoids re-reading 174 MB 10x)
# Fields in item lines are tab-separated: type TAB importance TAB content
# ---------------------------------------------------------------------------
METRICS=$(jq -r --argjson cutoff "$CUTOFF_MS" '
.nodes as $all |
# Real memory nodes — exclude InternalStateEvent and corrupted entries
($all | map(select(
.node_type != "InternalStateEvent" and
(.node_type | test("^[A-Za-z]+$"))
))) as $real |
# Created today
($real | map(select(.created_at > $cutoff))) as $new |
# Activated today but not created today (reinforced)
($real | map(select(
(.last_activated // 0) > $cutoff and
.created_at <= $cutoff
))) as $reinforced |
# Stats for sharpness (across all real nodes)
($real | length) as $real_count |
($real | if length > 0 then (map(.importance) | add / length) else 0 end) as $avg_imp |
($real | if length > 0 then (map(.confidence // 1) | add / length) else 0 end) as $avg_conf |
# activation_ratio: reinforced nodes today / total real nodes, capped 0-1
(($reinforced | length) as $ra |
if $real_count > 0 then ($ra / $real_count | if . > 1 then 1 else . end) else 0 end
) as $act_ratio |
# Sharpness score 0-100
((($avg_imp * 0.4) + ($avg_conf * 0.3) + ($act_ratio * 0.3)) * 100 | round) as $sharpness |
# Top new memories (by importance desc, cap 10)
($new | sort_by(-.importance) | .[0:10]) as $top_new |
# Top reinforced (by last_activated desc, cap 10)
($reinforced | sort_by(-.last_activated) | .[0:10]) as $top_reinforced |
# High-importance nodes (importance > 0.8), across all real nodes
($real | map(select(.importance > 0.8)) | length) as $high_imp_count |
# Scalar metrics
"TOTAL_REAL=\($real_count)",
"NEW_COUNT=\($new | length)",
"REINFORCED_COUNT=\($reinforced | length)",
"TOTAL_NODES=\($all | length)",
"AVG_IMP=\($avg_imp)",
"AVG_CONF=\($avg_conf)",
"ACT_RATIO=\($act_ratio)",
"SHARPNESS=\($sharpness)",
"HIGH_IMP=\($high_imp_count)",
# Item sections — fields separated by tab character (\t)
"---NEW---",
($top_new[] | [.node_type, (.importance | tostring), (.content[0:120] | gsub("\n";" "))] | join("\t")),
"---REINFORCED---",
($top_reinforced[] | [(.label[0:80] | gsub("\n";" ")), ("activated \(.activation_count)x total")] | join("\t"))
' "$SNAPSHOT" 2>/dev/null)
# ---------------------------------------------------------------------------
# Parse scalar metrics
# ---------------------------------------------------------------------------
parse() { printf '%s' "$METRICS" | grep "^$1=" | head -1 | cut -d= -f2-; }
TOTAL_REAL=$(parse TOTAL_REAL)
NEW_COUNT=$(parse NEW_COUNT)
REINFORCED_COUNT=$(parse REINFORCED_COUNT)
TOTAL_NODES=$(parse TOTAL_NODES)
AVG_IMP=$(parse AVG_IMP)
AVG_CONF=$(parse AVG_CONF)
ACT_RATIO=$(parse ACT_RATIO)
SHARPNESS=$(parse SHARPNESS)
HIGH_IMP=$(parse HIGH_IMP)
# Format floats to 2dp (use awk, avoiding bc locale issues)
fmt2() { awk "BEGIN{printf \"%.2f\", $1}"; }
fmt4() { awk "BEGIN{printf \"%.4f\", $1}"; }
AVG_IMP_FMT=$(fmt2 "$AVG_IMP")
AVG_CONF_FMT=$(fmt2 "$AVG_CONF")
ACT_RATIO_FMT=$(fmt4 "$ACT_RATIO")
IMP_CONTRIB=$(fmt4 "$(awk "BEGIN{printf \"%.6f\", $AVG_IMP * 0.4}")")
CONF_CONTRIB=$(fmt4 "$(awk "BEGIN{printf \"%.6f\", $AVG_CONF * 0.3}")")
ACT_CONTRIB=$(fmt4 "$(awk "BEGIN{printf \"%.6f\", $ACT_RATIO * 0.3}")")
# ---------------------------------------------------------------------------
# Sharpness delta (compare to yesterday)
# ---------------------------------------------------------------------------
DELTA_STR=""
if [[ -f "$SHARPNESS_FILE" ]]; then
YESTERDAY=$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "yesterday" +%Y-%m-%d 2>/dev/null || echo "")
if [[ -n "$YESTERDAY" ]]; then
PREV_SHARPNESS=$(jq -r --arg d "$YESTERDAY" '.[] | select(.date == $d) | .sharpness' "$SHARPNESS_FILE" 2>/dev/null | tail -1)
if [[ -n "$PREV_SHARPNESS" && "$PREV_SHARPNESS" != "null" ]]; then
DELTA=$(( SHARPNESS - PREV_SHARPNESS ))
if (( DELTA > 0 )); then
DELTA_STR=" (up ${DELTA}% from yesterday)"
elif (( DELTA < 0 )); then
DELTA_STR=" (down ${DELTA#-}% from yesterday)"
else
DELTA_STR=" (no change from yesterday)"
fi
fi
fi
fi
# ---------------------------------------------------------------------------
# Build new-memories section (tab-delimited: type TAB importance TAB content)
# ---------------------------------------------------------------------------
new_section() {
local lines
lines=$(printf '%s\n' "$METRICS" | awk '/^---NEW---/{found=1; next} /^---REINFORCED---/{exit} found{print}')
if [[ -z "$lines" ]]; then
echo " (none)"
return
fi
while IFS=$'\t' read -r ntype importance content; do
[[ -z "$ntype" ]] && continue
imp_fmt=$(awk "BEGIN{printf \"%.1f\", $importance}")
printf " [%-18s] (importance: %s) %s\n" "$ntype" "$imp_fmt" "$content"
done <<< "$lines"
}
# ---------------------------------------------------------------------------
# Build reinforced section (tab-delimited: label TAB activation-info)
# ---------------------------------------------------------------------------
reinforced_section() {
local lines
lines=$(printf '%s\n' "$METRICS" | awk '/^---REINFORCED---/{found=1; next} found{print}')
if [[ -z "$lines" ]]; then
echo " (none today)"
return
fi
while IFS=$'\t' read -r label acts; do
[[ -z "$label" ]] && continue
printf " \"%s\" — %s\n" "$label" "$acts"
done <<< "$lines"
}
# ---------------------------------------------------------------------------
# Render full digest
# ---------------------------------------------------------------------------
DIGEST=$(cat <<EOF
=== Neuron Cultivation Digest — ${DATE} ===
SHARPNESS: ${SHARPNESS}%${DELTA_STR}
TODAY'S MEMORIES (${NEW_COUNT} new):
$(new_section)
REINFORCED (${REINFORCED_COUNT} nodes re-activated today):
$(reinforced_section)
MEMORY HEALTH:
Total nodes (all): ${TOTAL_NODES}
Real memory nodes: ${TOTAL_REAL}
Avg importance: ${AVG_IMP_FMT}
Avg confidence: ${AVG_CONF_FMT}
High-importance nodes (>0.8): ${HIGH_IMP}
Nodes created today: ${NEW_COUNT}
Nodes re-activated today: ${REINFORCED_COUNT}
SHARPNESS FORMULA:
Sharpness = (avg_importance x 0.4) + (avg_confidence x 0.3) + (activation_ratio x 0.3)
avg_importance = ${AVG_IMP_FMT} -> ${AVG_IMP_FMT} x 0.4 = ${IMP_CONTRIB}
avg_confidence = ${AVG_CONF_FMT} -> ${AVG_CONF_FMT} x 0.3 = ${CONF_CONTRIB}
activation_ratio = ${ACT_RATIO_FMT} -> ratio x 0.3 = ${ACT_CONTRIB}
Result: ${SHARPNESS}%
Generated: $(date)
EOF
)
# ---------------------------------------------------------------------------
# Write digest file + print to stdout
# ---------------------------------------------------------------------------
printf '%s\n' "$DIGEST" | tee "$DIGEST_FILE"
# ---------------------------------------------------------------------------
# Append to sharpness.json
# ---------------------------------------------------------------------------
NEW_ENTRY="{\"date\":\"${DATE}\",\"sharpness\":${SHARPNESS},\"node_count\":${TOTAL_NODES},\"real_node_count\":${TOTAL_REAL},\"nodes_added\":${NEW_COUNT},\"nodes_reinforced\":${REINFORCED_COUNT}}"
if [[ -f "$SHARPNESS_FILE" ]]; then
UPDATED=$(jq --arg d "$DATE" --argjson entry "$NEW_ENTRY" '
map(select(.date != $d)) + [$entry]
' "$SHARPNESS_FILE" 2>/dev/null) || UPDATED="[$NEW_ENTRY]"
printf '%s\n' "$UPDATED" > "$SHARPNESS_FILE"
else
printf '[%s]\n' "$NEW_ENTRY" > "$SHARPNESS_FILE"
fi
echo ""
echo "Digest written to: $DIGEST_FILE"
echo "Sharpness log: $SHARPNESS_FILE"
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# memory-export.sh — Export Neuron engram store as a portable encrypted .neuronmem bundle
#
# Usage:
# ./tools/memory-export.sh [output-path] [--passphrase "your passphrase"]
#
# If no passphrase is given, a random one is generated and printed — write it down.
# If no output path is given, defaults to ./neuron-export-<timestamp>.neuronmem
set -euo pipefail
# ── Config ─────────────────────────────────────────────────────────────────────
ENGRAM_SNAPSHOT="${HOME}/.neuron/engram/snapshot.json"
SOUL_VERSION="1.1.0"
FORMAT_VERSION="1"
# ── Parse args ─────────────────────────────────────────────────────────────────
OUTPUT_PATH=""
PASSPHRASE=""
PASSPHRASE_SET=0
while [[ $# -gt 0 ]]; do
case "$1" in
--passphrase)
PASSPHRASE="$2"
PASSPHRASE_SET=1
shift 2
;;
--passphrase=*)
PASSPHRASE="${1#*=}"
PASSPHRASE_SET=1
shift
;;
-*)
echo "Unknown option: $1" >&2
echo "Usage: $0 [output-path] [--passphrase \"...\"]" >&2
exit 1
;;
*)
if [[ -z "$OUTPUT_PATH" ]]; then
OUTPUT_PATH="$1"
else
echo "Unexpected argument: $1" >&2
exit 1
fi
shift
;;
esac
done
# ── Default output path ────────────────────────────────────────────────────────
TIMESTAMP="$(date -u +"%Y%m%dT%H%M%SZ")"
if [[ -z "$OUTPUT_PATH" ]]; then
OUTPUT_PATH="./neuron-export-${TIMESTAMP}.neuronmem"
fi
# Ensure .neuronmem extension
if [[ "${OUTPUT_PATH}" != *.neuronmem ]]; then
OUTPUT_PATH="${OUTPUT_PATH%.neuronmem}.neuronmem"
fi
# ── Validate source ────────────────────────────────────────────────────────────
if [[ ! -f "$ENGRAM_SNAPSHOT" ]]; then
echo "ERROR: Engram snapshot not found at: $ENGRAM_SNAPSHOT" >&2
exit 1
fi
echo "Neuron Memory Export"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Source: $ENGRAM_SNAPSHOT"
echo "Output: $OUTPUT_PATH"
echo ""
# ── Generate passphrase if not provided ────────────────────────────────────────
if [[ $PASSPHRASE_SET -eq 0 ]]; then
PASSPHRASE="$(openssl rand -base64 32)"
echo "⚠ No passphrase provided. Generated passphrase:"
echo ""
echo " ${PASSPHRASE}"
echo ""
echo "⚠ WRITE THIS DOWN. You will need it to import this file."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
fi
# ── Count nodes and edges ──────────────────────────────────────────────────────
echo "Analyzing snapshot..."
NODE_COUNT="$(python3 -c "
import json, sys
with open('${ENGRAM_SNAPSHOT}') as f:
d = json.load(f)
nodes = d.get('nodes', d if isinstance(d, list) else [])
edges = d.get('edges', [])
print(len(nodes) if isinstance(nodes, list) else len(nodes))
" 2>/dev/null || echo "unknown")"
echo " Nodes: ${NODE_COUNT}"
# ── Compute checksum of source file ───────────────────────────────────────────
echo "Computing checksum..."
CHECKSUM="$(openssl dgst -sha256 "$ENGRAM_SNAPSHOT" | awk '{print $NF}')"
echo " SHA256: ${CHECKSUM:0:16}..."
# ── Build bundle in temp dir ───────────────────────────────────────────────────
WORK_DIR="$(mktemp -d)"
BUNDLE_DIR="${WORK_DIR}/neuronmem-v${FORMAT_VERSION}"
mkdir -p "$BUNDLE_DIR"
echo "Building bundle..."
# Copy snapshot as nodes.json
cp "$ENGRAM_SNAPSHOT" "${BUNDLE_DIR}/nodes.json"
# Write metadata.json
ISO_TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
cat > "${BUNDLE_DIR}/metadata.json" << METAEOF
{
"version": "${FORMAT_VERSION}",
"exported_at": "${ISO_TIMESTAMP}",
"node_count": ${NODE_COUNT},
"soul_version": "${SOUL_VERSION}",
"sha256": "${CHECKSUM}",
"format": "neuronmem-v1",
"encryption": "aes-256-cbc-pbkdf2",
"source_host": "$(hostname -s 2>/dev/null || echo unknown)"
}
METAEOF
echo " metadata.json written"
echo " nodes.json copied ($(du -sh "${BUNDLE_DIR}/nodes.json" | cut -f1))"
# ── Create tar.gz ──────────────────────────────────────────────────────────────
TAR_PATH="${WORK_DIR}/bundle.tar.gz"
echo "Compressing..."
(cd "$WORK_DIR" && tar czf "$TAR_PATH" "neuronmem-v${FORMAT_VERSION}/")
COMPRESSED_SIZE="$(du -sh "$TAR_PATH" | cut -f1)"
echo " Compressed size: ${COMPRESSED_SIZE}"
# ── Encrypt ────────────────────────────────────────────────────────────────────
echo "Encrypting (AES-256-CBC, PBKDF2, 600k iterations)..."
openssl enc -aes-256-cbc \
-pbkdf2 \
-iter 600000 \
-salt \
-in "$TAR_PATH" \
-out "$OUTPUT_PATH" \
-pass "pass:${PASSPHRASE}"
# ── Cleanup ────────────────────────────────────────────────────────────────────
rm -rf "$WORK_DIR"
# ── Report ─────────────────────────────────────────────────────────────────────
FINAL_SIZE="$(du -sh "$OUTPUT_PATH" | cut -f1)"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Export complete."
echo " File: $OUTPUT_PATH"
echo " Size: ${FINAL_SIZE}"
echo " Nodes: ${NODE_COUNT}"
echo " Checksum: ${CHECKSUM:0:32}..."
echo " Timestamp: ${ISO_TIMESTAMP}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env bash
# memory-import-refugee.sh — Import conversation/memory history from external apps into Neuron
#
# Usage:
# ./tools/memory-import-refugee.sh --format chatgpt conversations.json
# ./tools/memory-import-refugee.sh --format screenpipe screenpipe-export.json
# ./tools/memory-import-refugee.sh --format generic data.json[l]
#
# Supported formats:
# chatgpt — ChatGPT conversation export (conversations.json)
# screenpipe — Screenpipe OCR export (frames array)
# generic — Any JSON array or JSONL with content/text fields
#
# The script writes Memory nodes to the Neuron soul via its HTTP API.
# The soul must be running on localhost:7770.
set -euo pipefail
# ── Config ─────────────────────────────────────────────────────────────────────
SOUL_HOST="http://localhost:7770"
# Note: POST /api/neuron/memory ignores the label field (soul hardcodes "memory:remembered").
# We embed the label in the content prefix so it is searchable.
MEMORY_API="${SOUL_HOST}/api/neuron/memory"
SLEEP_MS=100 # ms between API calls (rate limiting)
# ── Dependency check ───────────────────────────────────────────────────────────
if ! command -v jq &>/dev/null; then
echo "ERROR: jq is required but not installed." >&2
echo "" >&2
echo "Install it with:" >&2
echo " macOS: brew install jq" >&2
echo " Ubuntu: sudo apt-get install jq" >&2
echo " Alpine: apk add jq" >&2
exit 1
fi
# ── Parse args ─────────────────────────────────────────────────────────────────
FORMAT=""
INPUT_FILE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--format|-f)
FORMAT="$2"
shift 2
;;
--format=*|-f=*)
FORMAT="${1#*=}"
shift
;;
-*)
echo "Unknown option: $1" >&2
echo "Usage: $0 --format <chatgpt|screenpipe|generic> <input-file>" >&2
exit 1
;;
*)
if [[ -z "$INPUT_FILE" ]]; then
INPUT_FILE="$1"
else
echo "Unexpected argument: $1" >&2
exit 1
fi
shift
;;
esac
done
if [[ -z "$FORMAT" ]]; then
echo "ERROR: --format is required." >&2
echo "Usage: $0 --format <chatgpt|screenpipe|generic> <input-file>" >&2
exit 1
fi
if [[ -z "$INPUT_FILE" ]]; then
echo "ERROR: No input file specified." >&2
echo "Usage: $0 --format <chatgpt|screenpipe|generic> <input-file>" >&2
exit 1
fi
if [[ ! -f "$INPUT_FILE" ]]; then
echo "ERROR: Input file not found: $INPUT_FILE" >&2
exit 1
fi
case "$FORMAT" in
chatgpt|screenpipe|generic) ;;
*)
echo "ERROR: Unknown format: $FORMAT" >&2
echo "Supported formats: chatgpt, screenpipe, generic" >&2
exit 1
;;
esac
# ── Soul health check ──────────────────────────────────────────────────────────
HTTP_CODE="$(curl -s -o /dev/null -w "%{http_code}" "${SOUL_HOST}/api/neuron/memory" 2>/dev/null || echo "000")"
if [[ "$HTTP_CODE" == "000" ]]; then
echo "ERROR: Neuron soul is not responding at ${SOUL_HOST}." >&2
echo " Start the soul service and retry." >&2
exit 1
fi
# ── Counters ───────────────────────────────────────────────────────────────────
IMPORTED=0
SKIPPED=0
ERRORS=0
# ── Helper: post one memory node ───────────────────────────────────────────────
# post_memory CONTENT LABEL TAGS_JSON
#
# Note: the soul's POST /api/neuron/memory API ignores the label field (hardcodes
# it to "memory:remembered"). We embed the label as a prefix in the content so
# the title remains searchable via recall/search.
post_memory() {
local content="$1"
local label="$2"
local tags_json="$3"
# Skip empty content
if [[ -z "$content" || "$content" == "null" ]]; then
SKIPPED=$((SKIPPED + 1))
return 0
fi
# Embed label in content so it's searchable (the API ignores the label field)
local full_content="[${label}] ${content}"
local payload
payload="$(jq -n \
--arg content "$full_content" \
--arg label "$label" \
--argjson tags "$tags_json" \
'{content: $content, label: $label, tags: $tags}')"
local response
response="$(curl -s -X POST "$MEMORY_API" \
-H "Content-Type: application/json" \
-d "$payload" 2>/dev/null)"
local ok
ok="$(echo "$response" | jq -r '.ok // "false"' 2>/dev/null)"
if [[ "$ok" == "true" ]]; then
IMPORTED=$((IMPORTED + 1))
else
ERRORS=$((ERRORS + 1))
echo " [ERROR] API error for label \"${label:0:60}\": $response" >&2
fi
# Rate limit: sleep 100ms
sleep "0.${SLEEP_MS}"
}
# ── Format: ChatGPT ────────────────────────────────────────────────────────────
import_chatgpt() {
echo "Format: ChatGPT conversation export"
# Validate: must be JSON array at top level
local top_type
top_type="$(jq -r 'type' "$INPUT_FILE" 2>/dev/null)"
if [[ "$top_type" != "array" ]]; then
echo "ERROR: ChatGPT export must be a JSON array of conversations." >&2
exit 1
fi
local conv_count
conv_count="$(jq 'length' "$INPUT_FILE")"
echo "Found ${conv_count} conversation(s) to process."
echo ""
# Count total user messages for progress display
local total_msgs
total_msgs="$(jq '[.[].mapping // {} | to_entries[] | .value.message | select(. != null and .author.role == "user") | .content.parts // [] | .[] | select(type == "string" and length > 0)] | length' "$INPUT_FILE" 2>/dev/null || echo "?")"
echo "Total user messages: ${total_msgs}"
echo ""
local msg_idx=0
# Process each conversation
while IFS= read -r conv_json; do
local title
title="$(echo "$conv_json" | jq -r '.title // "Untitled"')"
# Truncate label to 100 chars
local label="${title:0:100}"
# Extract user messages — ChatGPT export uses a mapping dict structure
# Mapping: { uuid: { id, message: { author: { role }, content: { parts: [...] } }, ... } }
# We iterate over mapping values, filter role=user, grab text parts
while IFS= read -r msg_text; do
msg_idx=$((msg_idx + 1))
echo " Importing ${msg_idx}/${total_msgs}..."
post_memory "$msg_text" "$label" '["chatgpt-import","conversation"]'
done < <(echo "$conv_json" | jq -r '
.mapping // {} |
to_entries[] |
.value.message |
select(. != null) |
select(.author.role == "user") |
.content.parts // [] |
.[] |
select(type == "string" and length > 0)
' 2>/dev/null)
done < <(jq -c '.[]' "$INPUT_FILE")
}
# ── Format: Screenpipe ─────────────────────────────────────────────────────────
import_screenpipe() {
echo "Format: Screenpipe OCR export"
# Validate: must have frames array
local top_type
top_type="$(jq -r 'type' "$INPUT_FILE" 2>/dev/null)"
if [[ "$top_type" != "object" ]]; then
echo "ERROR: Screenpipe export must be a JSON object with a 'frames' array." >&2
exit 1
fi
local frame_count
frame_count="$(jq '.frames | length' "$INPUT_FILE" 2>/dev/null || echo "0")"
echo "Found ${frame_count} frame(s) to process."
if [[ "$frame_count" == "0" ]]; then
echo "No frames found. Nothing to import."
return 0
fi
# Group frames by app_name + 5-minute window bucket
# Strategy: process sorted frames, emit a group when app or bucket changes.
# We do this in pure jq with a reduce, emitting groups as newline-delimited JSON.
local total_groups=0
local group_idx=0
# Collect groups: each group is { app, bucket_ts, texts: [...] }
# Bucket = floor(timestamp_epoch / 300) * 300 seconds
# timestamps may be ISO8601 or epoch — handle both
# We process in jq and emit one group per line as JSON
while IFS= read -r group_json; do
total_groups=$((total_groups + 1))
# Just count first
:
done < <(jq -c '
.frames |
map(select(.text != null and (.text | length) > 0)) |
group_by(.app_name) |
.[] |
. as $app_frames |
($app_frames[0].app_name) as $app |
# Sort by timestamp within app
(sort_by(.timestamp)) |
# Group into 5-minute buckets
reduce .[] as $f (
{bucket: null, texts: [], ts: null, groups: []};
($f.timestamp // "") as $ts |
# Derive numeric bucket: try epoch directly; for ISO use first 15 chars as bucket key
(if ($ts | test("^[0-9]+$")) then ($ts | tonumber / 300 | floor)
else ($ts[0:15])
end) as $bucket |
if .bucket == null then
{bucket: $bucket, texts: [$f.text], ts: $ts, groups: .groups}
elif .bucket == $bucket then
{bucket: $bucket, texts: (.texts + [$f.text]), ts: $ts, groups: .groups}
else
{bucket: $bucket, texts: [$f.text], ts: $ts,
groups: (.groups + [{app: $app, ts: .ts, texts: .texts}])}
end
) |
# flush last bucket
(.groups + [{app: .app_name, ts: .ts, texts: .texts}]) |
.[] |
select(.texts | length > 0)
' "$INPUT_FILE" 2>/dev/null)
# Now actually process
while IFS= read -r group_json; do
group_idx=$((group_idx + 1))
echo " Importing ${group_idx}..."
local app_name ts_str content label
app_name="$(echo "$group_json" | jq -r '.app // "unknown"')"
ts_str="$(echo "$group_json" | jq -r '.ts // ""')"
# Concatenate texts, truncate to 2000 chars
content="$(echo "$group_json" | jq -r '.texts | join(" ")' | cut -c1-2000)"
label="Screenpipe: ${app_name} at ${ts_str:0:16}"
local tags_json
tags_json="$(jq -n --arg app "$app_name" '["screenpipe-import","screen-capture",$app]')"
post_memory "$content" "$label" "$tags_json"
done < <(jq -c '
.frames |
map(select(.text != null and (.text | length) > 0)) |
group_by(.app_name) |
.[] |
. as $app_frames |
($app_frames[0].app_name) as $app |
(sort_by(.timestamp)) |
reduce .[] as $f (
{bucket: null, texts: [], ts: null, app: $app, groups: []};
($f.timestamp // "") as $ts |
(if ($ts | test("^[0-9]+$")) then ($ts | tonumber / 300 | floor | tostring)
else ($ts[0:15])
end) as $bucket |
if .bucket == null then
{bucket: $bucket, texts: [$f.text], ts: $ts, app: $app, groups: .groups}
elif .bucket == $bucket then
{bucket: $bucket, texts: (.texts + [$f.text]), ts: $ts, app: $app, groups: .groups}
else
{bucket: $bucket, texts: [$f.text], ts: $ts, app: $app,
groups: (.groups + [{app: $app, ts: .ts, texts: .texts}])}
end
) |
(.groups + [{app: .app, ts: .ts, texts: .texts}]) |
.[] |
select(.texts | length > 0)
' "$INPUT_FILE" 2>/dev/null)
}
# ── Format: Generic ────────────────────────────────────────────────────────────
import_generic() {
echo "Format: Generic JSON/JSONL"
# Detect if JSONL (one JSON object per line) or single JSON array/object
local first_char
first_char="$(head -c1 "$INPUT_FILE" 2>/dev/null)"
local records_file
records_file="$(mktemp)"
trap 'rm -f "$records_file"' RETURN
if [[ "$first_char" == "[" ]]; then
# JSON array — explode to one object per line
jq -c '.[]' "$INPUT_FILE" > "$records_file" 2>/dev/null || true
elif [[ "$first_char" == "{" ]]; then
# Single object or JSONL — try JSONL first
# JSONL: each line is valid JSON
# Check if the whole file is one object or multiple lines
local line_count
line_count="$(wc -l < "$INPUT_FILE" | tr -d ' ')"
if [[ "$line_count" -le 1 ]]; then
# Single object: wrap in array and explode
jq -c '[.] | .[]' "$INPUT_FILE" > "$records_file" 2>/dev/null || true
else
# Assume JSONL
cp "$INPUT_FILE" "$records_file"
fi
else
# Try JSONL anyway
cp "$INPUT_FILE" "$records_file"
fi
local total_records
total_records="$(wc -l < "$records_file" | tr -d ' ')"
echo "Found ${total_records} record(s) to process."
echo ""
local idx=0
while IFS= read -r record_json; do
[[ -z "$record_json" ]] && continue
idx=$((idx + 1))
echo " Importing ${idx}/${total_records}..."
# Extract content: prefer 'content', fall back to 'text', then 'body', then 'message'
local content
content="$(echo "$record_json" | jq -r '
if .content != null and (.content | type) == "string" then .content
elif .text != null and (.text | type) == "string" then .text
elif .body != null and (.body | type) == "string" then .body
elif .message != null and (.message | type) == "string" then .message
else ""
end
' 2>/dev/null)"
[[ -z "$content" || "$content" == "null" ]] && { SKIPPED=$((SKIPPED + 1)); continue; }
# Extract label: prefer 'title', then 'label', then 'name', then first 80 chars of content
local label
label="$(echo "$record_json" | jq -r '
if .title != null and (.title | type) == "string" then .title
elif .label != null and (.label | type) == "string" then .label
elif .name != null and (.name | type) == "string" then .name
else ""
end
' 2>/dev/null)"
if [[ -z "$label" || "$label" == "null" ]]; then
label="${content:0:80}"
fi
label="${label:0:100}"
post_memory "$content" "$label" '["imported","generic"]'
done < "$records_file"
}
# ── Main ───────────────────────────────────────────────────────────────────────
echo "Neuron Refugee Importer"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Source: $INPUT_FILE"
echo "Format: $FORMAT"
echo "Soul: $SOUL_HOST"
echo ""
case "$FORMAT" in
chatgpt) import_chatgpt ;;
screenpipe) import_screenpipe ;;
generic) import_generic ;;
esac
# ── Final report ───────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Import complete."
echo " Imported: ${IMPORTED}"
echo " Skipped: ${SKIPPED}"
echo " Errors: ${ERRORS}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [[ $ERRORS -gt 0 ]]; then
exit 1
fi
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env bash
# memory-import.sh — Import a Neuron .neuronmem bundle onto this device
#
# Usage:
# ./tools/memory-import.sh input.neuronmem [--passphrase "your passphrase"]
# ./tools/memory-import.sh input.neuronmem [--dry-run] # verify only, no changes
#
# The script will:
# 1. Decrypt and unpack the .neuronmem file
# 2. Validate the checksum and version
# 3. Back up the current snapshot.json
# 4. Stop the soul service
# 5. Replace snapshot.json
# 6. Restart the soul service
# 7. Verify the soul came back up
set -euo pipefail
# ── Config ─────────────────────────────────────────────────────────────────────
ENGRAM_SNAPSHOT="${HOME}/.neuron/engram/snapshot.json"
SOUL_SERVICE="ai.neurontechnologies.soul"
SOUL_PORT="7770"
SOUL_STARTUP_TIMEOUT=30 # seconds to wait for soul to come back
# ── Parse args ─────────────────────────────────────────────────────────────────
INPUT_PATH=""
PASSPHRASE=""
PASSPHRASE_SET=0
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--passphrase)
PASSPHRASE="$2"
PASSPHRASE_SET=1
shift 2
;;
--passphrase=*)
PASSPHRASE="${1#*=}"
PASSPHRASE_SET=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
-*)
echo "Unknown option: $1" >&2
echo "Usage: $0 input.neuronmem [--passphrase \"...\"] [--dry-run]" >&2
exit 1
;;
*)
if [[ -z "$INPUT_PATH" ]]; then
INPUT_PATH="$1"
else
echo "Unexpected argument: $1" >&2
exit 1
fi
shift
;;
esac
done
if [[ -z "$INPUT_PATH" ]]; then
echo "ERROR: No input file specified." >&2
echo "Usage: $0 input.neuronmem [--passphrase \"...\"] [--dry-run]" >&2
exit 1
fi
if [[ ! -f "$INPUT_PATH" ]]; then
echo "ERROR: Input file not found: $INPUT_PATH" >&2
exit 1
fi
echo "Neuron Memory Import"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Source: $INPUT_PATH"
echo "Target: $ENGRAM_SNAPSHOT"
if [[ $DRY_RUN -eq 1 ]]; then
echo "Mode: DRY RUN (no changes will be made)"
fi
echo ""
# ── Prompt for passphrase if needed ───────────────────────────────────────────
if [[ $PASSPHRASE_SET -eq 0 ]]; then
read -r -s -p "Enter passphrase: " PASSPHRASE
echo ""
if [[ -z "$PASSPHRASE" ]]; then
echo "ERROR: Passphrase cannot be empty." >&2
exit 1
fi
fi
# ── Decrypt to temp dir ────────────────────────────────────────────────────────
WORK_DIR="$(mktemp -d)"
CLEANUP() {
rm -rf "$WORK_DIR"
}
trap CLEANUP EXIT
TAR_PATH="${WORK_DIR}/bundle.tar.gz"
echo "Decrypting..."
if ! openssl enc -d -aes-256-cbc \
-pbkdf2 \
-iter 600000 \
-in "$INPUT_PATH" \
-out "$TAR_PATH" \
-pass "pass:${PASSPHRASE}" 2>/dev/null; then
echo "ERROR: Decryption failed. Wrong passphrase or corrupted file." >&2
exit 1
fi
echo " Decrypted successfully."
# ── Unpack ─────────────────────────────────────────────────────────────────────
echo "Unpacking..."
(cd "$WORK_DIR" && tar xzf "$TAR_PATH") || {
echo "ERROR: Failed to unpack bundle. File may be corrupted." >&2
exit 1
}
# Locate the bundle directory (neuronmem-v1/)
BUNDLE_DIR=""
for d in "${WORK_DIR}"/neuronmem-v*/; do
if [[ -d "$d" ]]; then
BUNDLE_DIR="$d"
break
fi
done
if [[ -z "$BUNDLE_DIR" ]]; then
echo "ERROR: Bundle directory not found. Invalid .neuronmem file." >&2
exit 1
fi
METADATA_FILE="${BUNDLE_DIR}metadata.json"
NODES_FILE="${BUNDLE_DIR}nodes.json"
if [[ ! -f "$METADATA_FILE" ]]; then
echo "ERROR: metadata.json missing from bundle." >&2
exit 1
fi
if [[ ! -f "$NODES_FILE" ]]; then
echo "ERROR: nodes.json missing from bundle." >&2
exit 1
fi
# ── Validate metadata ──────────────────────────────────────────────────────────
echo "Validating metadata..."
FORMAT_VERSION="$(python3 -c "import json; d=json.load(open('${METADATA_FILE}')); print(d.get('version','?'))")"
EXPORTED_AT="$(python3 -c "import json; d=json.load(open('${METADATA_FILE}')); print(d.get('exported_at','?'))")"
EXPECTED_COUNT="$(python3 -c "import json; d=json.load(open('${METADATA_FILE}')); print(d.get('node_count','?'))")"
STORED_CHECKSUM="$(python3 -c "import json; d=json.load(open('${METADATA_FILE}')); print(d.get('sha256','?'))")"
SOURCE_HOST="$(python3 -c "import json; d=json.load(open('${METADATA_FILE}')); print(d.get('source_host','?'))")"
echo " Format version: ${FORMAT_VERSION}"
echo " Exported at: ${EXPORTED_AT}"
echo " Source host: ${SOURCE_HOST}"
echo " Expected nodes: ${EXPECTED_COUNT}"
if [[ "$FORMAT_VERSION" != "1" ]]; then
echo "ERROR: Unsupported bundle format version: ${FORMAT_VERSION}" >&2
echo " This tool supports version 1 only." >&2
exit 1
fi
# ── Validate checksum ──────────────────────────────────────────────────────────
echo "Verifying checksum..."
ACTUAL_CHECKSUM="$(openssl dgst -sha256 "$NODES_FILE" | awk '{print $NF}')"
if [[ "$ACTUAL_CHECKSUM" != "$STORED_CHECKSUM" ]]; then
echo "ERROR: Checksum mismatch!" >&2
echo " Expected: ${STORED_CHECKSUM}" >&2
echo " Got: ${ACTUAL_CHECKSUM}" >&2
echo " The bundle may be corrupted." >&2
exit 1
fi
echo " Checksum OK: ${ACTUAL_CHECKSUM:0:16}..."
# ── Verify node count ──────────────────────────────────────────────────────────
echo "Verifying node count..."
ACTUAL_COUNT="$(python3 -c "
import json
with open('${NODES_FILE}') as f:
d = json.load(f)
nodes = d.get('nodes', d if isinstance(d, list) else [])
print(len(nodes) if isinstance(nodes, list) else len(nodes))
" 2>/dev/null || echo "unknown")"
echo " Found ${ACTUAL_COUNT} nodes (expected ${EXPECTED_COUNT})"
if [[ "$ACTUAL_COUNT" != "$EXPECTED_COUNT" && "$EXPECTED_COUNT" != "unknown" ]]; then
echo "WARNING: Node count mismatch (expected ${EXPECTED_COUNT}, found ${ACTUAL_COUNT})." >&2
echo " Proceeding anyway — count may differ if nodes were deduplicated." >&2
fi
# ── Dry run exit ───────────────────────────────────────────────────────────────
if [[ $DRY_RUN -eq 1 ]]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "DRY RUN complete. Bundle is valid."
echo " Nodes: ${ACTUAL_COUNT}"
echo " Checksum: verified"
echo " Run without --dry-run to import."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
# ── Safety confirmation ────────────────────────────────────────────────────────
echo ""
echo "WARNING: This will replace your current Neuron memory store."
echo " Current snapshot: $ENGRAM_SNAPSHOT"
echo " A backup will be created before replacing."
echo ""
read -r -p "Type 'yes' to continue: " CONFIRM
if [[ "$CONFIRM" != "yes" ]]; then
echo "Aborted."
exit 0
fi
# ── Backup existing snapshot ───────────────────────────────────────────────────
BACKUP_TIMESTAMP="$(date -u +"%Y%m%dT%H%M%SZ")"
ENGRAM_DIR="$(dirname "$ENGRAM_SNAPSHOT")"
BACKUP_PATH="${HOME}/.neuron/engram-backup-${BACKUP_TIMESTAMP}.tar.gz"
echo ""
echo "Backing up current snapshot..."
if [[ -f "$ENGRAM_SNAPSHOT" ]]; then
(cd "$HOME/.neuron" && tar czf "$BACKUP_PATH" "$(basename "$ENGRAM_DIR")/snapshot.json" 2>/dev/null) || \
cp "$ENGRAM_SNAPSHOT" "${ENGRAM_SNAPSHOT}.backup-${BACKUP_TIMESTAMP}"
echo " Backup: $BACKUP_PATH"
else
echo " No existing snapshot to back up."
fi
# ── Stop soul service ──────────────────────────────────────────────────────────
echo "Stopping soul service (${SOUL_SERVICE})..."
launchctl stop "$SOUL_SERVICE" 2>/dev/null || true
# Also stop engram service if running
launchctl stop "ai.neuron.engram" 2>/dev/null || true
sleep 2
echo " Soul stopped."
# ── Replace snapshot.json ──────────────────────────────────────────────────────
echo "Installing new snapshot..."
cp "$NODES_FILE" "$ENGRAM_SNAPSHOT"
echo " snapshot.json replaced ($(du -sh "$ENGRAM_SNAPSHOT" | cut -f1))"
# ── Restart soul service ───────────────────────────────────────────────────────
echo "Restarting soul service..."
launchctl start "$SOUL_SERVICE" 2>/dev/null || true
launchctl start "ai.neuron.engram" 2>/dev/null || true
# ── Wait for soul to come up ───────────────────────────────────────────────────
echo "Waiting for soul to come up on port ${SOUL_PORT}..."
ELAPSED=0
SOUL_UP=0
while [[ $ELAPSED -lt $SOUL_STARTUP_TIMEOUT ]]; do
if curl -sf "http://localhost:${SOUL_PORT}/" > /dev/null 2>&1; then
SOUL_UP=1
break
fi
# Try a known endpoint that returns any response (even 404 means it's up)
HTTP_CODE="$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${SOUL_PORT}/api/neuron/memory" 2>/dev/null || echo "000")"
if [[ "$HTTP_CODE" != "000" ]]; then
SOUL_UP=1
break
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if [[ $SOUL_UP -eq 1 ]]; then
echo " Soul is up (responded in ${ELAPSED}s)."
else
echo " WARNING: Soul did not respond within ${SOUL_STARTUP_TIMEOUT}s."
echo " The service may still be starting. Check: launchctl list | grep soul"
fi
# ── Final report ───────────────────────────────────────────────────────────────
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Import complete."
echo " Nodes imported: ${ACTUAL_COUNT}"
echo " Exported at: ${EXPORTED_AT}"
echo " Source host: ${SOURCE_HOST}"
echo " Backup: ${BACKUP_PATH}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# photo-to-memory.sh — OCR a document/photo and store the text in Neuron memory
#
# Uses GLM-OCR (0.9B, MIT) via mlx-vlm on Apple Silicon.
# Model auto-downloads ~1.59 GB to ~/.cache/huggingface/ on first run.
#
# Usage:
# ./tools/photo-to-memory.sh <image-file> [--dry-run] [--prompt "custom prompt"]
#
# Prerequisites:
# pip install -U mlx-vlm
#
# Examples:
# ./tools/photo-to-memory.sh ~/Desktop/receipt.jpg
# ./tools/photo-to-memory.sh ~/Documents/contract.png --dry-run
# ./tools/photo-to-memory.sh scan.jpg --prompt "Extract all text from this receipt"
set -euo pipefail
# ── Config ─────────────────────────────────────────────────────────────────────
SOUL_URL="${SOUL_URL:-http://localhost:7770}"
GLM_MODEL="${GLM_MODEL:-mlx-community/GLM-OCR-8bit}"
MAX_TOKENS="${MAX_TOKENS:-4096}"
DEFAULT_PROMPT="Extract all text from this document. Preserve structure including tables, headers, and lists. Output plain text."
# ── Colours ────────────────────────────────────────────────────────────────────
RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'
CYAN=$'\033[0;36m'; BOLD=$'\033[1m'; RESET=$'\033[0m'
log() { printf "%s%s%s\n" "$CYAN" "$*" "$RESET"; }
ok() { printf "%s✓ %s%s\n" "$GREEN" "$*" "$RESET"; }
warn() { printf "%s⚠ %s%s\n" "$YELLOW" "$*" "$RESET"; }
die() { printf "%s✗ %s%s\n" "$RED" "$*" "$RESET" >&2; exit 1; }
# ── Parse args ─────────────────────────────────────────────────────────────────
IMAGE_PATH=""
DRY_RUN=0
CUSTOM_PROMPT=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--prompt) CUSTOM_PROMPT="$2"; shift 2 ;;
--model) GLM_MODEL="$2"; shift 2 ;;
--help|-h)
sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
-*) die "Unknown option: $1" ;;
*)
[[ -n "$IMAGE_PATH" ]] && die "Only one image file at a time"
IMAGE_PATH="$1"
shift
;;
esac
done
[[ -z "$IMAGE_PATH" ]] && die "Usage: $0 <image-file> [--dry-run] [--prompt \"...\"]"
[[ -f "$IMAGE_PATH" ]] || die "File not found: $IMAGE_PATH"
PROMPT="${CUSTOM_PROMPT:-$DEFAULT_PROMPT}"
FILENAME=$(basename "$IMAGE_PATH")
ABS_PATH=$(realpath "$IMAGE_PATH")
# ── Check runtime ───────────────────────────────────────────────────────────────
if ! python3 -c "import mlx_vlm" 2>/dev/null; then
warn "mlx-vlm not installed. Installing now..."
pip install -q -U mlx-vlm || die "pip install mlx-vlm failed — run manually: pip install -U mlx-vlm"
fi
# ── Run GLM-OCR ─────────────────────────────────────────────────────────────────
log "Running GLM-OCR on: $FILENAME"
log "Model: $GLM_MODEL"
[[ "$DRY_RUN" -eq 1 ]] && warn "Dry-run mode — will not post to Neuron"
# GLM-OCR output goes to stdout; capture it
# First run downloads ~1.59 GB — this is expected and cached thereafter.
OCR_TEXT=$(python3 -m mlx_vlm.generate \
--model "$GLM_MODEL" \
--max-tokens "$MAX_TOKENS" \
--temperature 0.0 \
--prompt "$PROMPT" \
--image "$ABS_PATH" \
2>/dev/null) || die "GLM-OCR failed. Check that mlx-vlm is installed and the image is readable."
CHAR_COUNT=${#OCR_TEXT}
log "OCR complete — extracted ${CHAR_COUNT} characters"
if [[ "$CHAR_COUNT" -lt 5 ]]; then
warn "Very short output — the image may be blank or unreadable"
fi
# ── Preview ─────────────────────────────────────────────────────────────────────
printf "\n%s--- OCR output preview (first 400 chars) ---%s\n" "$BOLD" "$RESET"
printf "%s\n" "${OCR_TEXT:0:400}"
[[ "$CHAR_COUNT" -gt 400 ]] && printf "%s... [+%d more chars]%s\n" "$YELLOW" $((CHAR_COUNT - 400)) "$RESET"
printf "\n"
# ── Post to Neuron soul ─────────────────────────────────────────────────────────
if [[ "$DRY_RUN" -eq 1 ]]; then
ok "Dry-run complete — would POST ${CHAR_COUNT} chars to ${SOUL_URL}/api/neuron/memory"
exit 0
fi
log "Posting to Neuron soul at ${SOUL_URL} ..."
PAYLOAD=$(python3 -c "
import json, sys
content = sys.argv[1]
label = sys.argv[2]
tags = ['photo-import', 'ocr', 'glm-ocr']
print(json.dumps({'content': content, 'label': label, 'tags': tags}))
" "$OCR_TEXT" "Photo: ${FILENAME}")
HTTP_STATUS=$(curl -s -o /tmp/photo-to-memory-response.json -w "%{http_code}" \
-X POST "${SOUL_URL}/api/neuron/memory" \
-H "Content-Type: application/json" \
-d "$PAYLOAD")
if [[ "$HTTP_STATUS" =~ ^2 ]]; then
NODE_ID=$(python3 -c "
import json, sys
try:
d = json.load(open('/tmp/photo-to-memory-response.json'))
print(d.get('id', d.get('node_id', 'unknown')))
except Exception:
print('unknown')
")
ok "Memory node created: ${NODE_ID}"
ok "Label: Photo: ${FILENAME}"
ok "Tags: photo-import, ocr, glm-ocr"
else
BODY=$(cat /tmp/photo-to-memory-response.json 2>/dev/null || echo "(no body)")
die "Soul returned HTTP ${HTTP_STATUS}: ${BODY}"
fi