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:
Executable
+221
@@ -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"
|
||||
Executable
+162
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Executable
+427
@@ -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
|
||||
Executable
+289
@@ -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 "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
Executable
+135
@@ -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
|
||||
Reference in New Issue
Block a user