@adverant/nexus-memory-skill 2.3.4 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -30,6 +30,20 @@ When properly configured, Nexus Memory provides **fully automatic** memory manag
30
30
  - Captures: topics discussed, decisions made, problems solved
31
31
  - Stored with `event_type: "episode"` for easy retrieval
32
32
 
33
+ ### Auto-Ingest (File Detection) - NEW in v2.4.0
34
+ - **Automatic file ingestion** when Claude reads files or user mentions file paths
35
+ - Files are processed in the background (non-blocking)
36
+ - Full document storage in PostgreSQL + GraphRAG for semantic search
37
+ - **Triggers**:
38
+ - When Claude uses the Read tool to access a file
39
+ - When user mentions file paths in prompts (e.g., `/path/to/file.pdf`)
40
+ - **Excluded directories**: node_modules, .git, __pycache__, dist, build, vendor
41
+ - **Deduplication**: Files only re-ingested if modified since last ingestion
42
+ - **Status notifications**:
43
+ - Context injection shows "Files queued for ingestion..."
44
+ - Next prompt shows "✅ file.pdf - now searchable"
45
+ - Stale documents (>30 days) flagged for re-ingestion
46
+
33
47
  ### Configuration
34
48
 
35
49
  The automatic features are controlled by `~/.claude/settings.json`:
@@ -42,6 +56,7 @@ The automatic features are controlled by `~/.claude/settings.json`:
42
56
  "matcher": "",
43
57
  "hooks": [
44
58
  {"type": "command", "command": "~/.claude/hooks/auto-recall.sh"},
59
+ {"type": "command", "command": "~/.claude/hooks/auto-ingest.sh"},
45
60
  {"type": "command", "command": "~/.claude/hooks/store-memory.sh"}
46
61
  ]
47
62
  }
@@ -50,6 +65,7 @@ The automatic features are controlled by `~/.claude/settings.json`:
50
65
  {
51
66
  "matcher": "",
52
67
  "hooks": [
68
+ {"type": "command", "command": "~/.claude/hooks/auto-ingest.sh"},
53
69
  {"type": "command", "command": "~/.claude/hooks/store-memory.sh"},
54
70
  {"type": "command", "command": "~/.claude/hooks/episode-summary.sh"}
55
71
  ]
@@ -68,6 +84,9 @@ The automatic features are controlled by `~/.claude/settings.json`:
68
84
  | `NEXUS_RECALL_LIMIT` | `5` | Number of memories to auto-recall |
69
85
  | `NEXUS_EPISODE_THRESHOLD` | `10` | Tool count before episode summary |
70
86
  | `NEXUS_VERBOSE` | `0` | Set to `1` for debug output |
87
+ | `NEXUS_AUTO_INGEST` | `1` | Enable automatic file ingestion |
88
+ | `NEXUS_FRESHNESS_DAYS` | `30` | Days before document flagged as stale |
89
+ | `NEXUS_INGEST_POLL_INTERVAL` | `10` | Job status poll interval (seconds) |
71
90
 
72
91
  ---
73
92
 
@@ -0,0 +1,333 @@
1
+ #!/bin/bash
2
+ #
3
+ # Nexus Memory - Auto Ingest Hook
4
+ # Automatically detects files from Read tool usage or prompt mentions and queues
5
+ # them for background ingestion into GraphRAG.
6
+ #
7
+ # Triggers:
8
+ # - PostToolUse: When Claude uses the Read tool to access a file
9
+ # - UserPromptSubmit: When user mentions file paths in their prompt
10
+ #
11
+ # Features:
12
+ # - Automatic file path detection from tool input and prompts
13
+ # - Deduplication via mtime-based freshness tracking
14
+ # - Background ingestion (non-blocking)
15
+ # - Excluded directory filtering (node_modules, .git, etc.)
16
+ # - Context injection for immediate user feedback
17
+ #
18
+ # Environment Variables:
19
+ # NEXUS_API_KEY - API key for authentication (REQUIRED)
20
+ # NEXUS_AUTO_INGEST - Enable/disable auto-ingestion (default: 1)
21
+ # NEXUS_INGEST_VERBOSE - Enable debug output (default: 0)
22
+ # NEXUS_FRESHNESS_DAYS - Days before re-ingesting a file (default: 30)
23
+ #
24
+
25
+ set -o pipefail
26
+
27
+ # Configuration
28
+ NEXUS_API_KEY="${NEXUS_API_KEY:-}"
29
+ AUTO_INGEST="${NEXUS_AUTO_INGEST:-1}"
30
+ VERBOSE="${NEXUS_INGEST_VERBOSE:-${NEXUS_VERBOSE:-0}}"
31
+ FRESHNESS_DAYS="${NEXUS_FRESHNESS_DAYS:-30}"
32
+
33
+ # State directory
34
+ STATE_DIR="${HOME}/.claude/session-env/auto-ingest"
35
+ INGESTED_FILES="${STATE_DIR}/ingested_files.json"
36
+ PENDING_JOBS="${STATE_DIR}/pending_jobs.json"
37
+ NOTIFIER_PID="${STATE_DIR}/notifier.pid"
38
+
39
+ # Excluded directories (skip files in these paths)
40
+ EXCLUDED_DIRS=(
41
+ "node_modules"
42
+ ".git"
43
+ "__pycache__"
44
+ "dist"
45
+ "build"
46
+ "vendor"
47
+ ".venv"
48
+ "venv"
49
+ ".next"
50
+ ".nuxt"
51
+ "coverage"
52
+ ".cache"
53
+ ".npm"
54
+ ".yarn"
55
+ "bower_components"
56
+ "target"
57
+ "out"
58
+ ".gradle"
59
+ ".idea"
60
+ ".vscode"
61
+ )
62
+
63
+ # Logging functions
64
+ log() {
65
+ if [[ "$VERBOSE" == "1" ]]; then
66
+ echo "[auto-ingest] $1" >&2
67
+ fi
68
+ }
69
+
70
+ log_error() {
71
+ echo "[auto-ingest] ERROR: $1" >&2
72
+ }
73
+
74
+ # Skip if auto-ingest is disabled
75
+ if [[ "$AUTO_INGEST" != "1" ]]; then
76
+ log "Auto-ingest disabled (NEXUS_AUTO_INGEST=$AUTO_INGEST)"
77
+ exit 0
78
+ fi
79
+
80
+ # Skip if no API key (silently - don't block conversation)
81
+ if [[ -z "$NEXUS_API_KEY" ]]; then
82
+ log "NEXUS_API_KEY not set, skipping auto-ingest"
83
+ exit 0
84
+ fi
85
+
86
+ # Check dependencies silently
87
+ if ! command -v jq &> /dev/null; then
88
+ log "jq not installed, skipping auto-ingest"
89
+ exit 0
90
+ fi
91
+
92
+ # Initialize state directory
93
+ init_state() {
94
+ mkdir -p "$STATE_DIR"
95
+ [[ -f "$INGESTED_FILES" ]] || echo "{}" > "$INGESTED_FILES"
96
+ [[ -f "$PENDING_JOBS" ]] || echo "[]" > "$PENDING_JOBS"
97
+ }
98
+
99
+ # Check if file is in an excluded directory
100
+ is_excluded() {
101
+ local file_path="$1"
102
+
103
+ for dir in "${EXCLUDED_DIRS[@]}"; do
104
+ if [[ "$file_path" == *"/$dir/"* ]] || [[ "$file_path" == *"/$dir" ]]; then
105
+ log "Skipping excluded directory: $dir"
106
+ return 0
107
+ fi
108
+ done
109
+
110
+ return 1
111
+ }
112
+
113
+ # Get file modification time (cross-platform)
114
+ get_mtime() {
115
+ local file_path="$1"
116
+ if [[ "$(uname)" == "Darwin" ]]; then
117
+ stat -f %m "$file_path" 2>/dev/null
118
+ else
119
+ stat -c %Y "$file_path" 2>/dev/null
120
+ fi
121
+ }
122
+
123
+ # Check if file needs ingestion (not already ingested or modified since)
124
+ needs_ingestion() {
125
+ local file_path="$1"
126
+
127
+ # File must exist
128
+ if [[ ! -f "$file_path" ]]; then
129
+ log "File not found: $file_path"
130
+ return 1
131
+ fi
132
+
133
+ # Check if already pending
134
+ local is_pending=$(jq -r --arg p "$file_path" '[.[] | select(.path == $p)] | length' "$PENDING_JOBS" 2>/dev/null || echo "0")
135
+ if [[ "$is_pending" -gt 0 ]]; then
136
+ log "File already pending: $file_path"
137
+ return 1
138
+ fi
139
+
140
+ # Get current mtime
141
+ local current_mtime=$(get_mtime "$file_path")
142
+ if [[ -z "$current_mtime" ]]; then
143
+ log "Cannot get mtime for: $file_path"
144
+ return 1
145
+ fi
146
+
147
+ # Check ingested files index
148
+ local cached=$(jq -r --arg p "$file_path" '.[$p] // empty' "$INGESTED_FILES" 2>/dev/null)
149
+
150
+ if [[ -z "$cached" ]]; then
151
+ log "File never ingested: $file_path"
152
+ return 0 # Never ingested, needs ingestion
153
+ fi
154
+
155
+ local cached_mtime=$(echo "$cached" | jq -r '.mtime // 0')
156
+ local cached_status=$(echo "$cached" | jq -r '.status // "unknown"')
157
+
158
+ # Re-ingest if: modified since last ingest, or previous attempt failed
159
+ if [[ "$current_mtime" != "$cached_mtime" ]]; then
160
+ log "File modified since last ingest: $file_path (old: $cached_mtime, new: $current_mtime)"
161
+ return 0
162
+ fi
163
+
164
+ if [[ "$cached_status" == "failed" ]]; then
165
+ log "Previous ingestion failed, retrying: $file_path"
166
+ return 0
167
+ fi
168
+
169
+ log "File unchanged and already ingested: $file_path"
170
+ return 1
171
+ }
172
+
173
+ # Extract file paths from Read tool input
174
+ extract_read_tool_path() {
175
+ local input="$1"
176
+
177
+ # Check if this is a Read tool PostToolUse event
178
+ local tool_name=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null)
179
+
180
+ if [[ "$tool_name" == "Read" ]]; then
181
+ # Extract file_path from tool_input
182
+ local file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
183
+ if [[ -n "$file_path" ]] && [[ "$file_path" != "null" ]]; then
184
+ echo "$file_path"
185
+ fi
186
+ fi
187
+ }
188
+
189
+ # Extract file paths from prompt text using regex
190
+ extract_prompt_paths() {
191
+ local prompt="$1"
192
+
193
+ # Match absolute paths: /foo/bar/file.ext
194
+ # Match home paths: ~/project/file.ext
195
+ # Match relative paths: ./foo/bar.ts, ../src/index.js
196
+ # Require file extension to reduce false positives
197
+ echo "$prompt" | grep -oE '(~|\.{0,2})?/[A-Za-z0-9_./+-]+\.[A-Za-z0-9]+' 2>/dev/null | while read -r path; do
198
+ # Expand ~ to home directory
199
+ if [[ "$path" == ~* ]]; then
200
+ path="${path/#\~/$HOME}"
201
+ fi
202
+
203
+ # Resolve relative paths
204
+ if [[ "$path" == ./* ]] || [[ "$path" == ../* ]]; then
205
+ path=$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")
206
+ fi
207
+
208
+ # Only output if file exists
209
+ if [[ -f "$path" ]]; then
210
+ echo "$path"
211
+ fi
212
+ done | sort -u
213
+ }
214
+
215
+ # Queue file for background ingestion
216
+ queue_for_ingestion() {
217
+ local file_path="$1"
218
+ local file_name=$(basename "$file_path")
219
+
220
+ log "Queueing for ingestion: $file_path"
221
+
222
+ # Call upload-document.sh in background mode
223
+ local upload_script="${HOME}/.claude/hooks/upload-document.sh"
224
+
225
+ if [[ ! -x "$upload_script" ]]; then
226
+ log_error "upload-document.sh not found or not executable"
227
+ return 1
228
+ fi
229
+
230
+ # Run in background (fire-and-forget)
231
+ (
232
+ "$upload_script" "$file_path" --background 2>/dev/null
233
+ ) &
234
+ disown 2>/dev/null || true
235
+
236
+ return 0
237
+ }
238
+
239
+ # Output context injection for user feedback
240
+ emit_context() {
241
+ local files_queued="$1"
242
+ local count=$(echo "$files_queued" | wc -l | tr -d ' ')
243
+
244
+ if [[ -n "$files_queued" ]] && [[ "$count" -gt 0 ]]; then
245
+ echo ""
246
+ echo "<nexus-auto-ingest>"
247
+ echo "## Files Queued for Memory Ingestion"
248
+ echo "$files_queued" | while read -r path; do
249
+ if [[ -n "$path" ]]; then
250
+ echo "- $(basename "$path") - processing in background..."
251
+ fi
252
+ done
253
+ echo ""
254
+ echo "Files will be searchable via recall after processing completes."
255
+ echo "</nexus-auto-ingest>"
256
+ fi
257
+ }
258
+
259
+ # Main execution
260
+ main() {
261
+ init_state
262
+
263
+ # Read input from stdin
264
+ INPUT=$(cat)
265
+
266
+ if [[ -z "$INPUT" ]]; then
267
+ log "No input provided"
268
+ exit 0
269
+ fi
270
+
271
+ log "Received input: ${INPUT:0:200}..."
272
+
273
+ # Collect file paths to process
274
+ FILES_TO_INGEST=""
275
+
276
+ # 1. Check for Read tool usage (PostToolUse)
277
+ local read_path=$(extract_read_tool_path "$INPUT")
278
+ if [[ -n "$read_path" ]]; then
279
+ log "Detected Read tool file: $read_path"
280
+
281
+ if ! is_excluded "$read_path" && needs_ingestion "$read_path"; then
282
+ FILES_TO_INGEST="$read_path"
283
+ fi
284
+ fi
285
+
286
+ # 2. Check for file paths in prompt (UserPromptSubmit)
287
+ local prompt=$(echo "$INPUT" | jq -r '.prompt // .content // empty' 2>/dev/null)
288
+ if [[ -n "$prompt" ]] && [[ "$prompt" != "null" ]]; then
289
+ log "Scanning prompt for file paths..."
290
+
291
+ while IFS= read -r path; do
292
+ if [[ -n "$path" ]]; then
293
+ log "Detected prompt file: $path"
294
+
295
+ if ! is_excluded "$path" && needs_ingestion "$path"; then
296
+ if [[ -n "$FILES_TO_INGEST" ]]; then
297
+ FILES_TO_INGEST="${FILES_TO_INGEST}"$'\n'"${path}"
298
+ else
299
+ FILES_TO_INGEST="$path"
300
+ fi
301
+ fi
302
+ fi
303
+ done < <(extract_prompt_paths "$prompt")
304
+ fi
305
+
306
+ # Remove duplicates
307
+ if [[ -n "$FILES_TO_INGEST" ]]; then
308
+ FILES_TO_INGEST=$(echo "$FILES_TO_INGEST" | sort -u)
309
+ fi
310
+
311
+ # Queue files for ingestion
312
+ QUEUED_FILES=""
313
+ if [[ -n "$FILES_TO_INGEST" ]]; then
314
+ while IFS= read -r file_path; do
315
+ if [[ -n "$file_path" ]]; then
316
+ if queue_for_ingestion "$file_path"; then
317
+ if [[ -n "$QUEUED_FILES" ]]; then
318
+ QUEUED_FILES="${QUEUED_FILES}"$'\n'"${file_path}"
319
+ else
320
+ QUEUED_FILES="$file_path"
321
+ fi
322
+ fi
323
+ fi
324
+ done <<< "$FILES_TO_INGEST"
325
+ fi
326
+
327
+ # Emit context for user feedback
328
+ emit_context "$QUEUED_FILES"
329
+
330
+ exit 0
331
+ }
332
+
333
+ main
@@ -332,6 +332,67 @@ if [[ "$FOLLOWUP_COUNT" -gt 0 ]] && [[ "$FOLLOWUP_COUNT" != "null" ]] && [[ "$IN
332
332
  echo ""
333
333
  fi
334
334
 
335
+ # === AUTO-INGEST STATUS SECTION ===
336
+ AUTO_INGEST_STATE="${HOME}/.claude/session-env/auto-ingest"
337
+
338
+ # Show recently completed ingestions (cleared after display)
339
+ if [[ -f "${AUTO_INGEST_STATE}/recent_completions.json" ]]; then
340
+ COMPLETIONS=$(cat "${AUTO_INGEST_STATE}/recent_completions.json" 2>/dev/null || echo "[]")
341
+ COMPLETION_COUNT=$(echo "$COMPLETIONS" | jq 'length' 2>/dev/null || echo "0")
342
+
343
+ if [[ "$COMPLETION_COUNT" -gt 0 ]] && [[ "$COMPLETION_COUNT" != "0" ]]; then
344
+ echo "## Recently Ingested Files (Ready for Recall)"
345
+ echo "$COMPLETIONS" | jq -r '.[] |
346
+ if .status == "completed" then
347
+ "- ✅ \(.path | split("/") | .[-1]) - now searchable\(if .entityCount > 0 then " (\(.entityCount) entities)" else "" end)"
348
+ else
349
+ "- ❌ \(.path | split("/") | .[-1]) - ingestion failed"
350
+ end
351
+ ' 2>/dev/null
352
+ echo ""
353
+ # Clear after display
354
+ echo "[]" > "${AUTO_INGEST_STATE}/recent_completions.json"
355
+ fi
356
+ fi
357
+
358
+ # Show pending ingestions
359
+ if [[ -f "${AUTO_INGEST_STATE}/pending_jobs.json" ]]; then
360
+ PENDING=$(cat "${AUTO_INGEST_STATE}/pending_jobs.json" 2>/dev/null || echo "[]")
361
+ PENDING_COUNT=$(echo "$PENDING" | jq 'length' 2>/dev/null || echo "0")
362
+
363
+ if [[ "$PENDING_COUNT" -gt 0 ]] && [[ "$PENDING_COUNT" != "0" ]]; then
364
+ echo "## Files Currently Being Processed"
365
+ echo "$PENDING" | jq -r '.[] | "- ⏳ \(.path | split("/") | .[-1]) - processing..."' 2>/dev/null
366
+ echo ""
367
+ fi
368
+ fi
369
+
370
+ # Document freshness alerts (files ingested > 30 days ago)
371
+ FRESHNESS_DAYS="${NEXUS_FRESHNESS_DAYS:-30}"
372
+ if [[ -f "${AUTO_INGEST_STATE}/ingested_files.json" ]]; then
373
+ STALE_THRESHOLD=$((FRESHNESS_DAYS * 24 * 60 * 60)) # Convert days to seconds
374
+ NOW=$(date +%s)
375
+
376
+ # Find stale documents (completed but old)
377
+ STALE_DOCS=$(jq --argjson now "$NOW" --argjson thresh "$STALE_THRESHOLD" '
378
+ to_entries | map(select(
379
+ (.value.status == "completed") and
380
+ (.value.ingestedAt != null) and
381
+ (($now - (.value.ingestedAt | if type == "string" then (. | split("T")[0] | strptime("%Y-%m-%d") | mktime) else 0 end)) > $thresh)
382
+ )) | .[0:3]
383
+ ' "${AUTO_INGEST_STATE}/ingested_files.json" 2>/dev/null || echo "[]")
384
+
385
+ STALE_COUNT=$(echo "$STALE_DOCS" | jq 'length' 2>/dev/null || echo "0")
386
+
387
+ if [[ "$STALE_COUNT" -gt 0 ]] && [[ "$STALE_COUNT" != "0" ]]; then
388
+ echo "## Document Freshness Alerts"
389
+ echo "$STALE_DOCS" | jq -r --argjson days "$FRESHNESS_DAYS" '.[] |
390
+ "- ⚠️ \(.key | split("/") | .[-1]) - ingested >\($days) days ago, may be outdated"
391
+ ' 2>/dev/null
392
+ echo ""
393
+ fi
394
+ fi
395
+
335
396
  echo "</nexus-memory-context>"
336
397
 
337
398
  exit 0
@@ -0,0 +1,316 @@
1
+ #!/bin/bash
2
+ #
3
+ # Nexus Memory - Ingest Notify Daemon
4
+ # Background daemon that polls pending ingestion jobs and updates state files.
5
+ # Started by auto-ingest.sh when files are queued for ingestion.
6
+ #
7
+ # Features:
8
+ # - Polls job status every 10 seconds
9
+ # - Updates pending_jobs.json with current status
10
+ # - Moves completed jobs to recent_completions.json
11
+ # - Self-terminates when no pending jobs remain
12
+ # - Prevents duplicate daemon instances
13
+ #
14
+ # Environment Variables:
15
+ # NEXUS_API_KEY - API key for authentication (REQUIRED)
16
+ # NEXUS_API_URL - API endpoint (default: https://api.adverant.ai)
17
+ # NEXUS_INGEST_POLL_INTERVAL - Poll interval in seconds (default: 10)
18
+ # NEXUS_INGEST_VERBOSE - Enable debug output (default: 0)
19
+ #
20
+
21
+ set -o pipefail
22
+
23
+ # Configuration
24
+ NEXUS_API_KEY="${NEXUS_API_KEY:-}"
25
+ NEXUS_API_URL="${NEXUS_API_URL:-https://api.adverant.ai}"
26
+ POLL_INTERVAL="${NEXUS_INGEST_POLL_INTERVAL:-10}"
27
+ VERBOSE="${NEXUS_INGEST_VERBOSE:-${NEXUS_VERBOSE:-0}}"
28
+
29
+ # State files
30
+ STATE_DIR="${HOME}/.claude/session-env/auto-ingest"
31
+ INGESTED_FILES="${STATE_DIR}/ingested_files.json"
32
+ PENDING_JOBS="${STATE_DIR}/pending_jobs.json"
33
+ RECENT_COMPLETIONS="${STATE_DIR}/recent_completions.json"
34
+ PID_FILE="${STATE_DIR}/notifier.pid"
35
+
36
+ # Logging functions
37
+ log() {
38
+ if [[ "$VERBOSE" == "1" ]]; then
39
+ echo "[ingest-notify] $1" >&2
40
+ fi
41
+ }
42
+
43
+ log_error() {
44
+ echo "[ingest-notify] ERROR: $1" >&2
45
+ }
46
+
47
+ # Check if another instance is running
48
+ check_existing_instance() {
49
+ if [[ -f "$PID_FILE" ]]; then
50
+ local existing_pid=$(cat "$PID_FILE" 2>/dev/null)
51
+ if [[ -n "$existing_pid" ]] && kill -0 "$existing_pid" 2>/dev/null; then
52
+ log "Another notifier instance is running (PID: $existing_pid)"
53
+ exit 0
54
+ fi
55
+ # Stale PID file, remove it
56
+ rm -f "$PID_FILE"
57
+ fi
58
+ }
59
+
60
+ # Initialize state files
61
+ init_state() {
62
+ mkdir -p "$STATE_DIR"
63
+ [[ -f "$INGESTED_FILES" ]] || echo "{}" > "$INGESTED_FILES"
64
+ [[ -f "$PENDING_JOBS" ]] || echo "[]" > "$PENDING_JOBS"
65
+ [[ -f "$RECENT_COMPLETIONS" ]] || echo "[]" > "$RECENT_COMPLETIONS"
66
+ }
67
+
68
+ # Get file modification time (cross-platform)
69
+ get_mtime() {
70
+ local file_path="$1"
71
+ if [[ "$(uname)" == "Darwin" ]]; then
72
+ stat -f %m "$file_path" 2>/dev/null || echo "0"
73
+ else
74
+ stat -c %Y "$file_path" 2>/dev/null || echo "0"
75
+ fi
76
+ }
77
+
78
+ # Update ingested files index
79
+ update_ingested_file() {
80
+ local file_path="$1"
81
+ local job_id="$2"
82
+ local status="$3"
83
+ local entity_count="${4:-0}"
84
+
85
+ local mtime=$(get_mtime "$file_path")
86
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
87
+
88
+ # Atomic update using temp file
89
+ jq --arg p "$file_path" \
90
+ --arg j "$job_id" \
91
+ --arg s "$status" \
92
+ --arg m "$mtime" \
93
+ --arg t "$now" \
94
+ --argjson e "$entity_count" \
95
+ '.[$p] = {
96
+ jobId: $j,
97
+ status: $s,
98
+ mtime: ($m | tonumber),
99
+ ingestedAt: $t,
100
+ entityCount: $e
101
+ }' "$INGESTED_FILES" > "${INGESTED_FILES}.tmp" 2>/dev/null \
102
+ && mv "${INGESTED_FILES}.tmp" "$INGESTED_FILES"
103
+ }
104
+
105
+ # Add to recent completions
106
+ add_completion() {
107
+ local file_path="$1"
108
+ local job_id="$2"
109
+ local entity_count="${3:-0}"
110
+ local status="${4:-completed}"
111
+
112
+ local now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
113
+
114
+ # Read existing completions
115
+ local existing=$(cat "$RECENT_COMPLETIONS" 2>/dev/null || echo "[]")
116
+
117
+ # Append new completion
118
+ echo "$existing" | jq --arg p "$file_path" \
119
+ --arg j "$job_id" \
120
+ --arg t "$now" \
121
+ --arg s "$status" \
122
+ --argjson e "$entity_count" \
123
+ '. + [{
124
+ path: $p,
125
+ jobId: $j,
126
+ completedAt: $t,
127
+ status: $s,
128
+ entityCount: $e
129
+ }]' > "${RECENT_COMPLETIONS}.tmp" 2>/dev/null \
130
+ && mv "${RECENT_COMPLETIONS}.tmp" "$RECENT_COMPLETIONS"
131
+ }
132
+
133
+ # Check status of a single job
134
+ check_job_status() {
135
+ local job_id="$1"
136
+
137
+ local response=$(curl -s "$NEXUS_API_URL/fileprocess/api/jobs/$job_id" \
138
+ -H "Authorization: Bearer $NEXUS_API_KEY" \
139
+ --max-time 10 2>/dev/null)
140
+
141
+ if [[ -z "$response" ]]; then
142
+ echo '{"status": "unknown", "error": "No response from API"}'
143
+ return 1
144
+ fi
145
+
146
+ # Extract status from response (handles nested .job.status structure)
147
+ local status=$(echo "$response" | jq -r '.job.status // .status // "unknown"' 2>/dev/null)
148
+ local error=$(echo "$response" | jq -r '.job.errorMessage // .error // null' 2>/dev/null)
149
+ local entity_count=$(echo "$response" | jq -r '.job.metadata.entities // [] | length' 2>/dev/null || echo "0")
150
+
151
+ jq -n --arg s "$status" --arg e "$error" --argjson c "$entity_count" \
152
+ '{status: $s, error: $e, entityCount: $c}'
153
+ }
154
+
155
+ # Poll all pending jobs
156
+ poll_pending_jobs() {
157
+ local pending=$(cat "$PENDING_JOBS" 2>/dev/null || echo "[]")
158
+ local pending_count=$(echo "$pending" | jq 'length' 2>/dev/null || echo "0")
159
+
160
+ if [[ "$pending_count" == "0" ]]; then
161
+ log "No pending jobs"
162
+ return 0
163
+ fi
164
+
165
+ log "Polling $pending_count pending jobs..."
166
+
167
+ # Build new pending list (jobs still in progress)
168
+ local still_pending="[]"
169
+
170
+ # Process each pending job
171
+ echo "$pending" | jq -c '.[]' 2>/dev/null | while read -r job; do
172
+ local job_id=$(echo "$job" | jq -r '.jobId')
173
+ local file_path=$(echo "$job" | jq -r '.path')
174
+ local file_name=$(basename "$file_path")
175
+
176
+ log "Checking job $job_id for $file_name"
177
+
178
+ # Get job status
179
+ local status_json=$(check_job_status "$job_id")
180
+ local status=$(echo "$status_json" | jq -r '.status')
181
+ local entity_count=$(echo "$status_json" | jq -r '.entityCount // 0')
182
+
183
+ case "$status" in
184
+ "completed"|"finished"|"success")
185
+ log "Job completed: $file_name ($entity_count entities)"
186
+ update_ingested_file "$file_path" "$job_id" "completed" "$entity_count"
187
+ add_completion "$file_path" "$job_id" "$entity_count" "completed"
188
+ ;;
189
+
190
+ "failed"|"error"|"cancelled")
191
+ local error=$(echo "$status_json" | jq -r '.error // "Unknown error"')
192
+ log "Job failed: $file_name - $error"
193
+ update_ingested_file "$file_path" "$job_id" "failed" "0"
194
+ add_completion "$file_path" "$job_id" "0" "failed"
195
+ ;;
196
+
197
+ "queued"|"pending"|"processing"|"active"|"triaging"|"routing")
198
+ log "Job still processing: $file_name ($status)"
199
+ # Keep in pending list - will be rebuilt after loop
200
+ ;;
201
+
202
+ *)
203
+ log "Unknown status for $file_name: $status"
204
+ # Keep in pending list
205
+ ;;
206
+ esac
207
+ done
208
+
209
+ # Rebuild pending jobs list (only keep non-completed jobs)
210
+ local updated_pending=$(cat "$PENDING_JOBS" 2>/dev/null || echo "[]")
211
+ local new_pending="[]"
212
+
213
+ echo "$updated_pending" | jq -c '.[]' 2>/dev/null | while read -r job; do
214
+ local job_id=$(echo "$job" | jq -r '.jobId')
215
+ local file_path=$(echo "$job" | jq -r '.path')
216
+
217
+ # Check if this job is completed in ingested_files
218
+ local ingested_status=$(jq -r --arg p "$file_path" '.[$p].status // "pending"' "$INGESTED_FILES" 2>/dev/null)
219
+
220
+ if [[ "$ingested_status" != "completed" ]] && [[ "$ingested_status" != "failed" ]]; then
221
+ # Still pending, keep it
222
+ new_pending=$(echo "$new_pending" | jq --argjson job "$job" '. + [$job]')
223
+ fi
224
+ done
225
+
226
+ # Calculate remaining pending jobs
227
+ local final_pending=$(cat "$PENDING_JOBS" 2>/dev/null || echo "[]")
228
+ local remaining="[]"
229
+
230
+ while IFS= read -r job; do
231
+ if [[ -n "$job" ]]; then
232
+ local job_id=$(echo "$job" | jq -r '.jobId')
233
+ local file_path=$(echo "$job" | jq -r '.path')
234
+
235
+ # Check completion status
236
+ local ingested_status=$(jq -r --arg p "$file_path" '.[$p].status // "pending"' "$INGESTED_FILES" 2>/dev/null)
237
+
238
+ if [[ "$ingested_status" != "completed" ]] && [[ "$ingested_status" != "failed" ]]; then
239
+ remaining=$(echo "$remaining" | jq --argjson j "$job" '. + [$j]')
240
+ fi
241
+ fi
242
+ done < <(echo "$final_pending" | jq -c '.[]' 2>/dev/null)
243
+
244
+ echo "$remaining" > "$PENDING_JOBS"
245
+
246
+ local remaining_count=$(echo "$remaining" | jq 'length' 2>/dev/null || echo "0")
247
+ log "Remaining pending jobs: $remaining_count"
248
+
249
+ return "$remaining_count"
250
+ }
251
+
252
+ # Cleanup on exit
253
+ cleanup() {
254
+ rm -f "$PID_FILE"
255
+ log "Notifier daemon stopped"
256
+ }
257
+
258
+ # Main daemon loop
259
+ run_daemon() {
260
+ log "Starting notifier daemon (PID: $$)"
261
+ echo $$ > "$PID_FILE"
262
+
263
+ trap cleanup EXIT
264
+
265
+ local max_iterations=360 # 1 hour max (360 * 10s = 3600s)
266
+ local iteration=0
267
+
268
+ while [[ $iteration -lt $max_iterations ]]; do
269
+ poll_pending_jobs
270
+ local remaining=$?
271
+
272
+ # Exit if no more pending jobs
273
+ local pending_count=$(jq 'length' "$PENDING_JOBS" 2>/dev/null || echo "0")
274
+ if [[ "$pending_count" == "0" ]]; then
275
+ log "All jobs completed, exiting daemon"
276
+ break
277
+ fi
278
+
279
+ # Wait before next poll
280
+ sleep "$POLL_INTERVAL"
281
+ ((iteration++))
282
+ done
283
+
284
+ log "Daemon loop ended after $iteration iterations"
285
+ }
286
+
287
+ # Main entry point
288
+ main() {
289
+ # Skip if no API key
290
+ if [[ -z "$NEXUS_API_KEY" ]]; then
291
+ log "NEXUS_API_KEY not set, exiting"
292
+ exit 0
293
+ fi
294
+
295
+ # Check dependencies
296
+ if ! command -v jq &> /dev/null; then
297
+ log_error "jq not installed"
298
+ exit 1
299
+ fi
300
+
301
+ if ! command -v curl &> /dev/null; then
302
+ log_error "curl not installed"
303
+ exit 1
304
+ fi
305
+
306
+ # Check for existing instance
307
+ check_existing_instance
308
+
309
+ # Initialize state
310
+ init_state
311
+
312
+ # Run daemon
313
+ run_daemon
314
+ }
315
+
316
+ main
@@ -37,6 +37,7 @@
37
37
  # --tags=a,b,c Add custom tags for recall (comma-separated)
38
38
  # --no-entities Skip entity extraction to knowledge graph
39
39
  # --prefer-speed Use faster OCR (may reduce accuracy for scanned docs)
40
+ # --background Fire-and-forget mode for auto-ingestion (non-blocking)
40
41
  #
41
42
  # Environment Variables:
42
43
  # NEXUS_API_KEY - API key for authentication (REQUIRED)
@@ -158,6 +159,7 @@ BATCH_MODE=0
158
159
  CUSTOM_TAGS=""
159
160
  EXTRACT_ENTITIES=1
160
161
  PREFER_SPEED=0
162
+ BACKGROUND_MODE=0
161
163
 
162
164
  while [[ $# -gt 0 ]]; do
163
165
  case $1 in
@@ -185,6 +187,10 @@ while [[ $# -gt 0 ]]; do
185
187
  PREFER_SPEED=1
186
188
  shift
187
189
  ;;
190
+ --background)
191
+ BACKGROUND_MODE=1
192
+ shift
193
+ ;;
188
194
  --help|-h)
189
195
  print_usage
190
196
  exit 0
@@ -493,6 +499,77 @@ wait_for_job() {
493
499
  # MAIN EXECUTION
494
500
  # ============================================================================
495
501
 
502
+ # Background mode: Fire-and-forget for auto-ingestion
503
+ if [[ "$BACKGROUND_MODE" == "1" ]]; then
504
+ # Only process first file in background mode
505
+ FILE_PATH="${FILES[0]}"
506
+ FILE_NAME=$(basename "$FILE_PATH")
507
+
508
+ log "Background mode: uploading $FILE_NAME"
509
+
510
+ # Run upload in background subshell
511
+ (
512
+ # Suppress output in background mode
513
+ UPLOAD_OUTPUT=$(upload_file "$FILE_PATH" 2>&1)
514
+ UPLOAD_EXIT_CODE=$?
515
+
516
+ if [[ $UPLOAD_EXIT_CODE -eq 0 ]]; then
517
+ # Extract job ID from output
518
+ JOB_ID=$(echo "$UPLOAD_OUTPUT" | grep -E '^[a-f0-9-]+$' | tail -1)
519
+ if [[ -z "$JOB_ID" ]]; then
520
+ JOB_ID=$(echo "$UPLOAD_OUTPUT" | grep "Job ID:" | sed 's/Job ID: //' | tr -d ' ')
521
+ fi
522
+
523
+ if [[ -n "$JOB_ID" ]]; then
524
+ # Update state files for auto-ingest tracking
525
+ STATE_DIR="${HOME}/.claude/session-env/auto-ingest"
526
+ PENDING_FILE="${STATE_DIR}/pending_jobs.json"
527
+ INGESTED_FILE="${STATE_DIR}/ingested_files.json"
528
+
529
+ mkdir -p "$STATE_DIR"
530
+ [[ -f "$PENDING_FILE" ]] || echo "[]" > "$PENDING_FILE"
531
+ [[ -f "$INGESTED_FILE" ]] || echo "{}" > "$INGESTED_FILE"
532
+
533
+ # Add to pending jobs
534
+ NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
535
+ jq --arg j "$JOB_ID" --arg p "$FILE_PATH" --arg t "$NOW" \
536
+ '. + [{jobId: $j, path: $p, startTime: $t}]' "$PENDING_FILE" > "${PENDING_FILE}.tmp" 2>/dev/null \
537
+ && mv "${PENDING_FILE}.tmp" "$PENDING_FILE"
538
+
539
+ # Mark as processing in ingested files
540
+ if [[ "$(uname)" == "Darwin" ]]; then
541
+ MTIME=$(stat -f %m "$FILE_PATH" 2>/dev/null || echo "0")
542
+ else
543
+ MTIME=$(stat -c %Y "$FILE_PATH" 2>/dev/null || echo "0")
544
+ fi
545
+
546
+ jq --arg p "$FILE_PATH" --arg j "$JOB_ID" --arg m "$MTIME" --arg t "$NOW" \
547
+ '.[$p] = {jobId: $j, status: "processing", mtime: ($m | tonumber), ingestedAt: $t}' \
548
+ "$INGESTED_FILE" > "${INGESTED_FILE}.tmp" 2>/dev/null \
549
+ && mv "${INGESTED_FILE}.tmp" "$INGESTED_FILE"
550
+
551
+ # Start notifier daemon if not running
552
+ NOTIFIER_SCRIPT="${HOME}/.claude/hooks/ingest-notify.sh"
553
+ PID_FILE="${STATE_DIR}/notifier.pid"
554
+
555
+ if [[ -x "$NOTIFIER_SCRIPT" ]]; then
556
+ if [[ ! -f "$PID_FILE" ]] || ! kill -0 "$(cat "$PID_FILE" 2>/dev/null)" 2>/dev/null; then
557
+ "$NOTIFIER_SCRIPT" &
558
+ fi
559
+ fi
560
+
561
+ log "Background upload queued: $FILE_NAME (job: $JOB_ID)"
562
+ fi
563
+ else
564
+ log "Background upload failed: $FILE_NAME"
565
+ fi
566
+ ) &
567
+ disown 2>/dev/null || true
568
+
569
+ # Exit immediately (non-blocking)
570
+ exit 0
571
+ fi
572
+
496
573
  # Track all job IDs for batch mode
497
574
  JOB_IDS=()
498
575
  FILE_NAMES=()
package/install.sh CHANGED
@@ -116,6 +116,29 @@ install_files() {
116
116
  chmod +x ~/.claude/hooks/upload-document.sh
117
117
  fi
118
118
 
119
+ # Copy auto-ingest hooks (v2.4.0+)
120
+ if [ -f "$source_dir/hooks/auto-ingest.sh" ]; then
121
+ cp "$source_dir/hooks/auto-ingest.sh" ~/.claude/hooks/
122
+ chmod +x ~/.claude/hooks/auto-ingest.sh
123
+ fi
124
+
125
+ if [ -f "$source_dir/hooks/ingest-notify.sh" ]; then
126
+ cp "$source_dir/hooks/ingest-notify.sh" ~/.claude/hooks/
127
+ chmod +x ~/.claude/hooks/ingest-notify.sh
128
+ fi
129
+
130
+ # Copy API key helper if it exists
131
+ if [ -f "$source_dir/hooks/api-key-helper.sh" ]; then
132
+ cp "$source_dir/hooks/api-key-helper.sh" ~/.claude/hooks/
133
+ chmod +x ~/.claude/hooks/api-key-helper.sh
134
+ fi
135
+
136
+ # Copy bead-sync hook if it exists
137
+ if [ -f "$source_dir/hooks/bead-sync.sh" ]; then
138
+ cp "$source_dir/hooks/bead-sync.sh" ~/.claude/hooks/
139
+ chmod +x ~/.claude/hooks/bead-sync.sh
140
+ fi
141
+
119
142
  log_success "Files installed successfully"
120
143
  }
121
144
 
@@ -125,7 +148,7 @@ configure_settings() {
125
148
 
126
149
  local settings_file=~/.claude/settings.json
127
150
 
128
- # Create settings.json with automatic memory hooks
151
+ # Create settings.json with automatic memory hooks (including auto-ingest)
129
152
  cat > "$settings_file" << 'EOF'
130
153
  {
131
154
  "hooks": {
@@ -137,6 +160,10 @@ configure_settings() {
137
160
  "type": "command",
138
161
  "command": "~/.claude/hooks/auto-recall.sh"
139
162
  },
163
+ {
164
+ "type": "command",
165
+ "command": "~/.claude/hooks/auto-ingest.sh"
166
+ },
140
167
  {
141
168
  "type": "command",
142
169
  "command": "~/.claude/hooks/store-memory.sh"
@@ -148,6 +175,10 @@ configure_settings() {
148
175
  {
149
176
  "matcher": "",
150
177
  "hooks": [
178
+ {
179
+ "type": "command",
180
+ "command": "~/.claude/hooks/auto-ingest.sh"
181
+ },
151
182
  {
152
183
  "type": "command",
153
184
  "command": "~/.claude/hooks/store-memory.sh"
@@ -310,6 +341,7 @@ print_success() {
310
341
  echo "Automatic Memory Features Enabled:"
311
342
  echo " - Auto-recall: Relevant memories fetched on every prompt"
312
343
  echo " - Auto-store: Every prompt and tool use captured"
344
+ echo " - Auto-ingest: Files read by Claude auto-ingested to memory"
313
345
  echo " - Episode summaries: Generated every 10 tool uses"
314
346
  echo ""
315
347
  echo "Manual Commands:"
@@ -319,6 +351,8 @@ print_success() {
319
351
  echo "Environment Variables:"
320
352
  echo " NEXUS_VERBOSE=1 Enable debug output"
321
353
  echo " NEXUS_RECALL_LIMIT=10 Number of memories to auto-recall"
354
+ echo " NEXUS_AUTO_INGEST=0 Disable auto-ingestion of files"
355
+ echo " NEXUS_FRESHNESS_DAYS=30 Days before document flagged as stale"
322
356
  echo ""
323
357
  echo "Documentation: https://github.com/adverant/nexus-memory-skill"
324
358
  echo ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adverant/nexus-memory-skill",
3
- "version": "2.3.4",
3
+ "version": "2.4.0",
4
4
  "description": "Claude Code skill for persistent memory via Nexus GraphRAG - store and recall memories across all sessions and projects",
5
5
  "main": "SKILL.md",
6
6
  "type": "module",