Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c221104bc0 | ||
|
|
253aa4b5fc | ||
|
|
82362e4f3f | ||
|
|
7675109afc | ||
|
|
42fc14a5b3 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,7 +10,7 @@ test_keys.txt
|
||||
|
||||
/mongoose/
|
||||
/Trash/
|
||||
|
||||
deploy_lt.sh
|
||||
|
||||
# Build artifacts
|
||||
/build/
|
||||
|
||||
@@ -55,11 +55,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e
|
||||
|
||||
Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers.
|
||||
|
||||
## Current Status — v0.0.77
|
||||
## Current Status — v0.0.82
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.0.77 — Wizard now verifies systemd service is active after install, reports admin inbox expectation, and exits to avoid duplicate foreground boot
|
||||
> Last release update: v0.0.82 — Move model/task persistence to kind 30078, add finish_reason stall diagnostics, and remove local config/tasks file coupling
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
|
||||
@@ -40,10 +40,11 @@
|
||||
// triggered skills, HTTP API prompt runs, and local HTTP fetch tool.
|
||||
"tools": {
|
||||
"enabled": true,
|
||||
"max_turns": 8, // default max turns for normal agent tool loops
|
||||
"trigger_max_turns": 6, // max turns for triggered-skill executions
|
||||
"api_default_max_turns": 4, // default when HTTP body omits max_turns
|
||||
"api_max_turns_ceiling": 16, // hard cap applied to API-provided max_turns
|
||||
"max_turns": 20, // default max turns for normal agent tool loops
|
||||
"trigger_max_turns": 12, // max turns for triggered-skill executions
|
||||
"api_default_max_turns": 8, // default when HTTP body omits max_turns
|
||||
"api_max_turns_ceiling": 32, // hard cap applied to API-provided max_turns
|
||||
"stall_repeat_threshold": 3, // stop early when identical tool-call turns repeat this many times
|
||||
"local_http_fetch_default_timeout_seconds": 20,
|
||||
"local_http_fetch_max_timeout_seconds": 120,
|
||||
"shell": {
|
||||
|
||||
43
deploy_lt.sh
43
deploy_lt.sh
@@ -4,19 +4,48 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_BINARY="$SCRIPT_DIR/didactyl_static_x86_64"
|
||||
REMOTE_TARGET="ubuntu@laantungir.net:~/didactyl"
|
||||
REMOTE_HOST="ubuntu@laantungir.net"
|
||||
REMOTE_TARGETS=(
|
||||
"/home/simon/didactyl"
|
||||
"/home/didactyl/didactyl"
|
||||
)
|
||||
REMOTE_SERVICES=(
|
||||
"simon.service"
|
||||
"didactyl.service"
|
||||
)
|
||||
|
||||
if ! command -v rsync >/dev/null 2>&1; then
|
||||
echo "ERROR: rsync is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$SOURCE_BINARY" ]; then
|
||||
echo "ERROR: Source binary not found: $SOURCE_BINARY"
|
||||
echo "Build it first so didactyl_static_x86_64 exists."
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
echo "ERROR: ssh is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploying $SOURCE_BINARY to $REMOTE_TARGET"
|
||||
rsync -avz --progress "$SOURCE_BINARY" "$REMOTE_TARGET"
|
||||
echo "Deployment complete."
|
||||
echo "Building static binary first..."
|
||||
"$SCRIPT_DIR/build_static.sh"
|
||||
|
||||
if [ ! -f "$SOURCE_BINARY" ]; then
|
||||
echo "ERROR: Build completed but source binary not found: $SOURCE_BINARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REMOTE_STAGE="/tmp/didactyl_static_x86_64"
|
||||
|
||||
echo "Uploading $SOURCE_BINARY to staging path $REMOTE_HOST:$REMOTE_STAGE"
|
||||
rsync -avz --progress "$SOURCE_BINARY" "$REMOTE_HOST:$REMOTE_STAGE"
|
||||
|
||||
for target in "${REMOTE_TARGETS[@]}"; do
|
||||
echo "Installing staged binary to $target via sudo"
|
||||
ssh "$REMOTE_HOST" "sudo install -m 0755 '$REMOTE_STAGE' '$target'"
|
||||
done
|
||||
|
||||
echo "Restarting services on $REMOTE_HOST: ${REMOTE_SERVICES[*]}"
|
||||
ssh "$REMOTE_HOST" "sudo systemctl restart ${REMOTE_SERVICES[*]}"
|
||||
|
||||
echo "Cleaning up remote staging file $REMOTE_STAGE"
|
||||
ssh "$REMOTE_HOST" "rm -f '$REMOTE_STAGE'"
|
||||
|
||||
echo "Deployment complete for all targets and services."
|
||||
91
plans/default_skill_missing_from_cache.md
Normal file
91
plans/default_skill_missing_from_cache.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Fix: Default Skill Missing from skill_list Cache
|
||||
|
||||
## Problem
|
||||
|
||||
When a new didactyl agent is created via the setup wizard, the default skill (`didactyl-default`, kind 31124) does not appear in the `skill_list` output. The agent IS using the default skill as its system context, but the skill cache (`g_self_skill_events`) does not contain it.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Default skill event IS published to relays (confirmed by `nostr_my_events`)
|
||||
- Default skill event shows `in_current_cache: false`
|
||||
- `skill_list` returns `count: 1` with only `infrastructure-monitor`
|
||||
- The kind 10123 adoption list only contains `infrastructure-monitor`
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The startup sequence in `main.c` is:
|
||||
|
||||
```
|
||||
1. nostr_handler_init() — resets g_self_skill_events to []
|
||||
2. wait_for_connected_relays() — waits for relay connections
|
||||
3. reconcile_startup_events() — publishes default skill to relays
|
||||
-> publish_kind_event_to_relays() — inserts into cache IF sent > 0
|
||||
4. ... relay list expansion ...
|
||||
5. subscribe_self_skills() — subscribes to relays for kinds 31123/31124/10123
|
||||
```
|
||||
|
||||
The default skill SHOULD be inserted into the cache at step 3 via `publish_kind_event_to_relays` -> `self_skill_cache_upsert_event_locked`. However, the cache insert at line 2750 is gated by `if (sent > 0)`.
|
||||
|
||||
There are two failure modes:
|
||||
|
||||
### Failure Mode 1: Async publish timing
|
||||
The startup publish creates a NEW event per relay via `publish_pending_startup_events_for_relay_index`. Each call to `publish_kind_event_to_relays` creates a fresh event with `nostr_create_and_sign_event`. If the relay is not connected at that moment, `sent = 0` and the cache insert is skipped.
|
||||
|
||||
### Failure Mode 2: Subscription race condition
|
||||
Even if the cache insert succeeds at step 3, the self-skill subscription at step 5 queries relays. For a brand new agent, the default skill was JUST published asynchronously. If the relay hasn't indexed it by the time the subscription query runs, the subscription returns EOSE without the default skill. The subscription stays open but the default skill was published BEFORE the subscription started, so it won't arrive as a live event.
|
||||
|
||||
The cache entry from step 3 should persist, but there may be an edge case where the relay pool reconnection handler at line 792 triggers another publish cycle, creating a new event that replaces the cache entry, and if that publish fails (sent=0), the replacement doesn't happen but the old entry is still there.
|
||||
|
||||
### Most Likely Cause
|
||||
The most likely cause is that `publish_kind_event_to_relays` returns `sent = 0` for the default skill during the initial reconcile. This can happen if:
|
||||
- The relay connection drops momentarily between `wait_for_connected_relays` and the actual publish
|
||||
- The relay pool's async publish fails silently
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
### Approach: Seed the cache directly from config during reconcile
|
||||
|
||||
In `nostr_handler_reconcile_startup_events()`, after publishing startup events, explicitly seed the self-skill cache with a synthetic event built from `g_cfg->default_skill`. This ensures the default skill is ALWAYS in the cache regardless of relay publish success.
|
||||
|
||||
### Implementation
|
||||
|
||||
In `nostr_handler.c`, add a new static function `seed_default_skill_into_cache()` that:
|
||||
|
||||
1. Checks if `g_cfg->default_skill.content` is non-empty
|
||||
2. Builds a cJSON event object with:
|
||||
- `kind`: `g_cfg->default_skill.kind` (31124)
|
||||
- `pubkey`: `g_cfg->keys.public_key_hex`
|
||||
- `content`: `g_cfg->default_skill.content`
|
||||
- `tags`: parsed from `g_cfg->default_skill.tags_json`
|
||||
- `created_at`: `time(NULL)` (or 0 as a floor value)
|
||||
- `id`: a placeholder hex string (e.g., all zeros) — will be replaced when the real event arrives from relay
|
||||
3. Calls `self_skill_cache_upsert_event_locked()` with this synthetic event
|
||||
|
||||
Call this function at the END of `nostr_handler_reconcile_startup_events()`, AFTER the publish loop. This way:
|
||||
- If the publish succeeded, the cache already has the real event (with real ID/sig). The synthetic event would have `created_at` <= the real one, so the upsert would be a no-op.
|
||||
- If the publish failed, the cache gets the synthetic event as a fallback.
|
||||
- When the subscription later returns the real event from relays, it replaces the synthetic one (since it has a real ID and potentially newer timestamp).
|
||||
|
||||
### Alternative Approach: Remove the `sent > 0` gate
|
||||
|
||||
Change `publish_kind_event_to_relays` to always insert skill events (kinds 31123/31124) into the cache, regardless of whether the relay publish succeeded. This is simpler but changes the semantics — the cache would contain events that may not be on any relay.
|
||||
|
||||
### Recommended: Approach 1 (seed from config)
|
||||
|
||||
This is safer because:
|
||||
- It doesn't change the publish function's behavior
|
||||
- It explicitly handles the default skill case
|
||||
- The synthetic event gets replaced by the real one when it arrives from relays
|
||||
- It works even if the publish completely fails
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. `src/nostr_handler.c`:
|
||||
- Add `seed_default_skill_into_cache()` static function
|
||||
- Call it at the end of `nostr_handler_reconcile_startup_events()`
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- If the default skill is later updated via `skill_update`, the relay version will have a newer `created_at` and will replace the synthetic entry
|
||||
- If the agent has no default skill configured, the seed function is a no-op
|
||||
- The synthetic event has a placeholder ID, so `in_current_cache` checks by event ID won't match it — but `skill_list` iterates all events and doesn't check by ID
|
||||
149
plans/enhanced_existing_agent_wizard.md
Normal file
149
plans/enhanced_existing_agent_wizard.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Enhanced Existing Agent Wizard Flow
|
||||
|
||||
## Problem
|
||||
|
||||
When choosing "existing agent" in the wizard, the current flow:
|
||||
1. Asks for nsec
|
||||
2. Recovers kind 10002 relay list from Nostr
|
||||
3. Immediately boots the agent
|
||||
|
||||
This is insufficient when installing an existing agent on a **new server** because:
|
||||
- You cannot review or change the admin pubkey
|
||||
- You cannot review or change the LLM provider/model/API key
|
||||
- You cannot install a systemd service with a dedicated user
|
||||
- You cannot see what config was recovered from Nostr
|
||||
- The agent name is not recovered from the kind 0 profile
|
||||
|
||||
## Current Code
|
||||
|
||||
- [`existing_agent_flow()`](src/setup_wizard.c:1795) — 22 lines, minimal recovery
|
||||
- [`recover_existing_config_from_nostr()`](src/setup_wizard.c:792) — only recovers kind 10002 relays
|
||||
- Returns `SETUP_WIZARD_RC_EXISTING` (2) which does NOT set `bootstrap_mode`
|
||||
- In `main.c`, existing agents skip `reconcile_startup_events()` unless `first_run` is detected
|
||||
|
||||
## Data Available on Nostr for an Existing Agent
|
||||
|
||||
| Data | Kind | Storage | Recovery Method |
|
||||
|------|------|---------|-----------------|
|
||||
| Relay list | 10002 | Public tags | `query_and_extract_kind10002_relays()` |
|
||||
| Agent profile/name | 0 | Public JSON content | Query kind 0 by agent pubkey |
|
||||
| LLM config | 30078 d=llm_config | NIP-44 encrypted to self | `fetch_self_config_plaintext()` |
|
||||
| Agent config - admin pubkey, DM protocol | 30078 d=agent_config | NIP-44 encrypted to self | `fetch_self_config_plaintext()` |
|
||||
| Default skill | 31124 d=didactyl-default | Public content | Query kind 31124 by agent pubkey |
|
||||
| Adoption list | 10123 | Public tags | Query kind 10123 by agent pubkey |
|
||||
|
||||
## Proposed Enhanced Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Enter nsec] --> B[Connect to default relays]
|
||||
B --> C[Recover kind 10002 relay list]
|
||||
C --> D{Relay list found?}
|
||||
D -->|No| E{Offer to create new agent with this nsec}
|
||||
E -->|Yes| E2[Jump to new_agent_flow with nsec pre-loaded]
|
||||
E -->|No| E3[Return to main menu]
|
||||
D -->|Yes| F[Reconnect with recovered relays]
|
||||
F --> G[Recover all config from Nostr]
|
||||
G --> H[Display recovered config summary]
|
||||
H --> I{Review each setting}
|
||||
I -->|Keep all| J[Review summary + launch options]
|
||||
I -->|Change admin| K[Prompt new admin pubkey]
|
||||
I -->|Change LLM| L[Prompt LLM config]
|
||||
I -->|Change relays| M[Prompt relay config]
|
||||
K --> I
|
||||
L --> I
|
||||
M --> I
|
||||
J --> N{Launch option}
|
||||
N -->|Boot now| O[Return EXISTING]
|
||||
N -->|Install systemd| P[Install dedicated-user service]
|
||||
N -->|Quit| Q[Exit]
|
||||
```
|
||||
|
||||
### Step-by-step
|
||||
|
||||
#### Step 1: Identity — Enter nsec
|
||||
Same as current. Derive keys from nsec.
|
||||
|
||||
#### Step 2: Recovery — Connect and fetch config from Nostr
|
||||
1. Init nostr handler with default relays
|
||||
2. Wait for relay connections
|
||||
3. Query kind 10002 for relay list — if not found, offer to create a new agent with this nsec (jump to `new_agent_flow` with keys pre-loaded, skipping identity step)
|
||||
4. Cleanup and re-init with recovered relays
|
||||
5. Wait for relay connections on recovered relays
|
||||
6. Query kind 0 for agent profile — extract display_name/name
|
||||
7. Query kind 30078 d=llm_config — decrypt and parse LLM settings
|
||||
8. Query kind 30078 d=agent_config — decrypt and parse admin pubkey + DM protocol
|
||||
9. Cleanup nostr handler
|
||||
|
||||
#### Step 3: Review — Present recovered config
|
||||
Display all recovered values:
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Existing Agent -- Recovered Configuration │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Agent name: Simon │
|
||||
│ Identity: b27072b7fc2edf45... │
|
||||
│ Admin: a1b2c3d4e5f6... │
|
||||
│ LLM Provider: ppq │
|
||||
│ LLM Model: claude-haiku-4.5 │
|
||||
│ LLM Base URL: https://api.ppq.ai │
|
||||
│ LLM API Key: sk-...**** │
|
||||
│ DM Protocol: nip04 │
|
||||
│ Relays: 5 configured │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Then offer a menu:
|
||||
```
|
||||
[a] change Admin pubkey
|
||||
[l] change LLM provider/model/key
|
||||
[r] change Relay configuration
|
||||
[c] continue with these settings
|
||||
[q] quit
|
||||
```
|
||||
|
||||
Each change option reuses the existing prompt functions (`prompt_admin_pubkey`, `prompt_llm_config`, `prompt_relay_configuration`) but with context-appropriate headers.
|
||||
|
||||
After any change, redisplay the summary and menu.
|
||||
|
||||
#### Step 4: Launch — Boot or install systemd
|
||||
Same as the new-agent flow's final step:
|
||||
```
|
||||
[b] boot the agent now
|
||||
[i] install dedicated-user systemd service and boot
|
||||
[q] quit
|
||||
```
|
||||
|
||||
The systemd install reuses `install_system_service_with_dedicated_user()`.
|
||||
|
||||
### Return Codes
|
||||
|
||||
- If user chooses "boot now": return `SETUP_WIZARD_RC_EXISTING` (2) — same as current
|
||||
- If user chooses "install systemd": return `SETUP_WIZARD_RC_EXIT` (1) — agent runs as systemd service
|
||||
- If user changed config values: the `main()` flow should still work because `recover_missing_runtime_config_from_nostr()` at line 1108 will fill in any gaps, and the existing agent path at line 1163 loads system context from adopted skills
|
||||
|
||||
### Key Consideration: bootstrap_mode for changed configs
|
||||
|
||||
If the user changes LLM or admin config in the wizard, those changes need to be persisted back to Nostr. Currently, `persist_runtime_config_to_nostr()` is only called when `bootstrap_mode || first_run`.
|
||||
|
||||
**Solution**: When the existing-agent wizard detects that config was changed, return `SETUP_WIZARD_RC_BOOTSTRAP` (0) instead of `SETUP_WIZARD_RC_EXISTING` (2). This triggers the full reconcile path which persists the updated config.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
1. **`src/setup_wizard.c`**:
|
||||
- Add `recover_full_config_from_nostr()` — fetches kind 0, kind 30078 llm_config, kind 30078 agent_config
|
||||
- Add `query_self_kind0_name()` — queries agent's own kind 0 profile for name
|
||||
- Add `fetch_and_decrypt_self_config()` — replicates `fetch_self_config_plaintext()` logic from main.c for use in wizard context
|
||||
- Rewrite `existing_agent_flow()` with the enhanced multi-step flow
|
||||
- Make `prompt_admin_pubkey()`, `prompt_llm_config()`, `prompt_relay_configuration()` accept a context string parameter for the page header, or add wrapper versions for the existing-agent context
|
||||
|
||||
2. **`src/main.c`**:
|
||||
- Potentially expose `fetch_self_config_plaintext()`, `apply_recalled_llm_config()`, `apply_recalled_agent_config()` as non-static, OR duplicate the logic in setup_wizard.c
|
||||
- Better approach: move these to a shared location or make them accessible via a header
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The wizard already calls `nostr_handler_init()` / `nostr_handler_cleanup()` for validation queries. The enhanced flow will do the same but with two init/cleanup cycles: first with default relays to get kind 10002, then with recovered relays to get everything else.
|
||||
- The `prompt_admin_pubkey()` and `prompt_llm_config()` functions currently have hardcoded step headers like "Step 3 of 7". These should be parameterized or have existing-agent variants.
|
||||
- The `fetch_self_config_plaintext()` function in main.c requires an active nostr handler. The wizard will need to have the handler initialized when calling it.
|
||||
- API key display should be masked — show only first 4 and last 4 characters.
|
||||
69
plans/fix_kind30078_agent_config_publish.md
Normal file
69
plans/fix_kind30078_agent_config_publish.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Fix: Kind 30078 `d=agent_config` Publish Gaps
|
||||
|
||||
## Problem
|
||||
|
||||
The agent stores its admin pubkey and DM protocol in a NIP-44 self-encrypted kind 30078 replaceable event (`d=agent_config`). This event is critical for the `--nsec`-only startup path (used by systemd services) to recover the admin identity.
|
||||
|
||||
**Multiple startup paths fail to publish this event**, causing:
|
||||
- Agents installed via wizard "Install Service" to lose their admin pubkey on restart
|
||||
- Agents recovering on new servers to use stale/old admin pubkeys from relays
|
||||
- Agents with genesis files to silently ignore newer kind 30078 values
|
||||
|
||||
## Affected Files
|
||||
|
||||
- `src/setup_wizard.c` — wizard flows
|
||||
- `src/main.c` — main startup logic
|
||||
|
||||
## Fixes
|
||||
|
||||
### Fix A: `new_agent_flow` — publish before service install
|
||||
**File:** `src/setup_wizard.c` ~line 2184
|
||||
**Problem:** When user chooses "Install systemd service" in new agent flow, `persist_runtime_config_to_nostr_wizard_online` is never called. The service starts with `--nsec` only and has no kind 30078 to recover from.
|
||||
**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before `install_system_service_with_dedicated_user()`. Fail the install if publish fails (same pattern as existing_agent_flow).
|
||||
|
||||
### Fix B: `new_agent_flow` — publish before "boot now" return
|
||||
**File:** `src/setup_wizard.c` ~line 2177
|
||||
**Problem:** When user chooses "Boot now", the wizard returns `SETUP_WIZARD_RC_BOOTSTRAP` and relies on `main()` to publish later. This works but is fragile — if the bootstrap publish in `main()` fails, the kind 30078 is never created.
|
||||
**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before returning 0. This is belt-and-suspenders — `main()` will also publish, but the wizard ensures it happens at least once. If the wizard publish fails, log a warning but continue (non-fatal since main will retry).
|
||||
|
||||
### Fix C: `existing_agent_flow` — always publish before service install
|
||||
**File:** `src/setup_wizard.c` ~line 2368
|
||||
**Problem:** `persist_runtime_config_to_nostr_wizard_online` is only called `if (config_changed)`. If the user accepts the recovered config as-is, the kind 30078 is not re-published. The event may have been lost from relays.
|
||||
**Fix:** Remove the `config_changed` gate. Always call `persist_runtime_config_to_nostr_wizard_online(cfg)` before installing the service. Kind 30078 is a replaceable event, so re-publishing is safe and idempotent.
|
||||
|
||||
### Fix D: `existing_agent_flow` — always return BOOTSTRAP
|
||||
**File:** `src/setup_wizard.c` ~line 2364
|
||||
**Problem:** When user chooses "Boot now" and `config_changed == 0`, the flow returns `SETUP_WIZARD_RC_EXISTING` which means `bootstrap_mode = 0` in `main()`. If `first_run = 0` (kind 10002 exists), `persist_runtime_config_to_nostr` is skipped.
|
||||
**Fix:** Always return `SETUP_WIZARD_RC_BOOTSTRAP` from the "boot now" path, regardless of `config_changed`. This ensures `main()` always re-publishes the kind 30078. The wizard has all the config in memory — it should always be treated as authoritative.
|
||||
|
||||
### Fix E: `persist_runtime_config_to_nostr_wizard_online` — ensure relay delivery
|
||||
**File:** `src/setup_wizard.c` ~line 1159
|
||||
**Problem:** The function publishes the event and immediately calls `nostr_handler_cleanup()`. The event may not have been delivered to relays yet (fire-and-forget).
|
||||
**Fix:** Add a brief poll loop (e.g., 2-3 seconds of `nostr_handler_poll()`) after the publish call and before `nostr_handler_cleanup()` to give the event time to propagate. This matches the pattern used elsewhere for relay operations.
|
||||
|
||||
### Fix F: `main()` — always fetch kind 30078 and compare
|
||||
**File:** `src/main.c` ~line 847 (`recover_missing_runtime_config_from_nostr`)
|
||||
**Problem:** The recovery only runs when `admin_config_is_complete()` returns false. If a genesis file provides an admin pubkey, the kind 30078 is never checked, even if it has a different (newer) value.
|
||||
**Fix:** Always fetch the kind 30078 `d=agent_config` regardless of whether the genesis already provided values. Compare the fetched admin_pubkey with the loaded one. If they differ, log a `DEBUG_WARN` with both values. Genesis wins (operator intent), but the warning makes the conflict visible in logs.
|
||||
|
||||
### Fix G: `main()` — always re-publish kind 30078
|
||||
**File:** `src/main.c` ~line 1152
|
||||
**Problem:** `persist_runtime_config_to_nostr` only runs when `bootstrap_mode || first_run`. On subsequent runs, the kind 30078 is never refreshed. If relays purge the event, it's gone.
|
||||
**Fix:** Move `persist_runtime_config_to_nostr(&cfg)` outside the `if (bootstrap_mode || first_run)` block so it runs on every startup. Kind 30078 is a replaceable event — re-publishing is safe, idempotent, and ensures relay persistence. Log the result but don't fail startup if it doesn't succeed.
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Fix E first (relay delivery) — this makes all other wizard publishes more reliable
|
||||
2. Fixes A + B (new_agent_flow) — the most critical bug
|
||||
3. Fixes C + D (existing_agent_flow) — second most critical
|
||||
4. Fix G (always re-publish in main) — ensures long-term relay persistence
|
||||
5. Fix F (conflict detection) — nice-to-have logging improvement
|
||||
|
||||
## Testing
|
||||
|
||||
- Wizard → New Agent → Install Service: verify kind 30078 exists on relays after service starts
|
||||
- Wizard → New Agent → Boot: verify kind 30078 exists on relays
|
||||
- Wizard → Existing Agent → Install Service (no changes): verify kind 30078 re-published
|
||||
- Wizard → Existing Agent → Boot (no changes): verify kind 30078 re-published
|
||||
- `--nsec` only restart: verify kind 30078 recovered and re-published
|
||||
- `--config genesis.jsonc` with different admin than kind 30078: verify warning logged, genesis wins, kind 30078 updated
|
||||
146
plans/fix_local_file_persistence_and_stall_diagnostic.md
Normal file
146
plans/fix_local_file_persistence_and_stall_diagnostic.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Fix: Local File Persistence & Stall Diagnostic Improvements
|
||||
|
||||
## Context
|
||||
|
||||
The agent got stuck in a stall loop when trying to create a skill via DM. The root cause is `max_tokens=512` truncating the LLM's tool call JSON. The admin tried to fix it with `/model_set {"max_tokens": 10000}` but got `"failed to persist llm config to config file"` because `model_set` tries to write to a local config file that doesn't exist in the Nostr-native deployment model.
|
||||
|
||||
Investigation revealed two tools that incorrectly persist state to local files instead of kind 30078 Nostr events, plus missing diagnostic information in the stall detector.
|
||||
|
||||
## Hardcoded File Locations to Remove
|
||||
|
||||
### tool_model.c
|
||||
- `ctx->cfg->config_path` — references a local config file (genesis.jsonc or config.jsonc)
|
||||
- The entire `persist_llm_config()` function (lines 82-139) reads/writes this file
|
||||
- The `read_entire_file_local()` helper (lines 42-80) exists only for this purpose
|
||||
|
||||
### tool_task.c
|
||||
- `"tasks.json"` — hardcoded at line 188: `build_tool_path_local(ctx, "tasks.json", ...)`
|
||||
- `tasks_load_root_local()` (lines 82-141) reads from this file
|
||||
- `tasks_save_root_local()` (lines 143-159) writes to this file
|
||||
|
||||
### tools_schema.c
|
||||
- Line 985: `"Update active LLM configuration and persist it to config.jsonc"` — mentions config.jsonc
|
||||
- Line 1285: `"Build current task list context block from tasks.json"` — mentions tasks.json
|
||||
- Line 1314: `"Manage agent short-term task memory stored in tasks.json"` — mentions tasks.json
|
||||
|
||||
## Fixes
|
||||
|
||||
### Fix 1: model_set — Persist to Kind 30078
|
||||
|
||||
**File:** `src/tools/tool_model.c`
|
||||
|
||||
Replace `persist_llm_config()` (lines 82-139) with a function that:
|
||||
1. Builds a JSON object with LLM config fields (provider, model, base_url, max_tokens, temperature — NOT api_key for security)
|
||||
2. NIP-44 self-encrypts it using the same pattern as `config_store` in `tool_config.c`
|
||||
3. Publishes as kind 30078 with tags `["d", "llm_config"]` and `["app", "didactyl"]`
|
||||
|
||||
Delete the `read_entire_file_local()` helper (lines 42-80) and the `json_object_set_string()` helper (lines 33-40) — they only exist for the file-based persist.
|
||||
|
||||
Also update `execute_model_set()` (line 234) to call the new Nostr-based persist, and return success with a warning if persist fails (since runtime update already succeeded at line 229).
|
||||
|
||||
### Fix 2: model_set — Don't Fail When Only Persist Fails
|
||||
|
||||
**File:** `src/tools/tool_model.c`, lines 229-236
|
||||
|
||||
Currently:
|
||||
```c
|
||||
if (llm_set_config(&cfg) != 0) {
|
||||
return json_error_local("failed to update runtime llm config");
|
||||
}
|
||||
ctx->cfg->llm = cfg;
|
||||
if (persist_llm_config(ctx, &cfg) != 0) {
|
||||
return json_error_local("failed to persist llm config to config file");
|
||||
}
|
||||
```
|
||||
|
||||
Change to: return success with a `"persist_warning"` field if persist fails, since the runtime update already took effect.
|
||||
|
||||
### Fix 3: task_manage — Persist to Kind 30078
|
||||
|
||||
**File:** `src/tools/tool_task.c`
|
||||
|
||||
Replace `tasks_load_root_local()` and `tasks_save_root_local()` with:
|
||||
- `tasks_load_root_nostr()` — queries kind 30078 `d=tasks` from self, NIP-44 decrypts, parses JSON
|
||||
- `tasks_save_root_nostr()` — serializes JSON, NIP-44 self-encrypts, publishes kind 30078 `d=tasks`
|
||||
|
||||
This follows the exact same pattern as `config_store`/`config_recall` in `tool_config.c` and `memory_save`/`memory_recall` in `tool_memory.c`.
|
||||
|
||||
Remove the hardcoded `"tasks.json"` string at line 188 and the `build_tool_path_local()` call for it.
|
||||
|
||||
Remove `tasks_path` from the response JSON at line 424.
|
||||
|
||||
### Fix 4: Update Schema Descriptions
|
||||
|
||||
**File:** `src/tools/tools_schema.c`
|
||||
|
||||
- Line 985: Change `"Update active LLM configuration and persist it to config.jsonc"` to `"Update active LLM configuration and persist it to Nostr"`
|
||||
- Line 1285: Change `"Build current task list context block from tasks.json"` to `"Build current task list context block from agent task memory"`
|
||||
- Line 1314: Change `"Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)"` to `"Manage agent short-term task memory stored on Nostr (list/add/update/remove/clear/replace)"`
|
||||
|
||||
### Fix 5: Add finish_reason to LLM Response
|
||||
|
||||
**File:** `src/llm.h`
|
||||
|
||||
Add `char* finish_reason;` to `llm_response_t`:
|
||||
```c
|
||||
typedef struct {
|
||||
char* content;
|
||||
llm_tool_call_t* tool_calls;
|
||||
int tool_call_count;
|
||||
char* finish_reason;
|
||||
} llm_response_t;
|
||||
```
|
||||
|
||||
**File:** `src/llm.c`
|
||||
|
||||
In `parse_llm_response()` (line 276), after extracting `msg` from `choices[0]`, extract `finish_reason`:
|
||||
```c
|
||||
cJSON* fr = first ? cJSON_GetObjectItemCaseSensitive(first, "finish_reason") : NULL;
|
||||
if (fr && cJSON_IsString(fr) && fr->valuestring) {
|
||||
out->finish_reason = strdup(fr->valuestring);
|
||||
}
|
||||
```
|
||||
|
||||
In `llm_response_free()`, add `free(response->finish_reason);`.
|
||||
|
||||
### Fix 6: Add max_tokens Truncation Hint to Stall Diagnostic
|
||||
|
||||
**File:** `src/agent.c`
|
||||
|
||||
In the stall detection block at line 2217, before freeing `resp`, check `finish_reason`:
|
||||
```c
|
||||
if (repeated_tool_turns >= stall_repeat_threshold) {
|
||||
int truncated = resp.finish_reason && strcmp(resp.finish_reason, "length") == 0;
|
||||
exited_on_stall = 1;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
In `notify_admin_limit_diagnostic()` (line 106), add fields:
|
||||
- `possible_cause` — "max_tokens_truncation" when finish_reason was "length"
|
||||
- `current_max_tokens` — the current max_tokens value
|
||||
- `hint` — "Increase max_tokens: /model_set {\"max_tokens\": 4096}"
|
||||
|
||||
**File:** `src/http_api.c`
|
||||
|
||||
Same changes in the HTTP API tool loop stall detection at line 966.
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. Fix 5 (finish_reason in llm_response_t) — no dependencies
|
||||
2. Fix 6 (stall diagnostic) — depends on Fix 5
|
||||
3. Fix 1 (model_set persist to Nostr) — independent
|
||||
4. Fix 2 (model_set graceful failure) — can be done with Fix 1
|
||||
5. Fix 3 (task_manage persist to Nostr) — independent
|
||||
6. Fix 4 (schema descriptions) — do last, trivial
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `src/llm.h` — add finish_reason field
|
||||
- `src/llm.c` — parse finish_reason, free it
|
||||
- `src/agent.c` — stall diagnostic with truncation hint
|
||||
- `src/http_api.c` — stall diagnostic with truncation hint
|
||||
- `src/tools/tool_model.c` — replace file persist with kind 30078, graceful failure
|
||||
- `src/tools/tool_task.c` — replace file I/O with kind 30078
|
||||
- `src/tools/tools_schema.c` — update 3 descriptions
|
||||
144
src/agent.c
144
src/agent.c
@@ -79,6 +79,72 @@ static uint64_t message_fingerprint(const char* sender_pubkey_hex, const char* m
|
||||
return a ^ (b + 0x9e3779b97f4a7c15ULL + (a << 6) + (a >> 2));
|
||||
}
|
||||
|
||||
static uint64_t tool_calls_fingerprint(const llm_response_t* resp) {
|
||||
if (!resp || resp->tool_call_count <= 0 || !resp->tool_calls) {
|
||||
return 1469598103934665603ULL;
|
||||
}
|
||||
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
for (int i = 0; i < resp->tool_call_count; i++) {
|
||||
const llm_tool_call_t* tc = &resp->tool_calls[i];
|
||||
uint64_t name_h = fnv1a64(tc->name ? tc->name : "");
|
||||
uint64_t args_h = fnv1a64(tc->arguments_json ? tc->arguments_json : "{}");
|
||||
uint64_t part = name_h ^ (args_h + 0x9e3779b97f4a7c15ULL + (name_h << 6) + (name_h >> 2));
|
||||
h ^= part + (uint64_t)(i + 1) * 1099511628211ULL;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
|
||||
h ^= (uint64_t)resp->tool_call_count;
|
||||
h *= 1099511628211ULL;
|
||||
return h;
|
||||
}
|
||||
|
||||
static void notify_admin_limit_diagnostic(const char* source,
|
||||
const char* reason,
|
||||
const char* subject,
|
||||
int max_turns,
|
||||
int turns_run,
|
||||
int stall_repeat_threshold,
|
||||
int repeated_tool_turns,
|
||||
int current_max_tokens,
|
||||
const char* possible_cause,
|
||||
const char* hint,
|
||||
const char* final_answer) {
|
||||
if (!g_cfg || g_cfg->admin.pubkey[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
char diag[1800];
|
||||
snprintf(diag,
|
||||
sizeof(diag),
|
||||
"⚠️ Didactyl limit diagnostic\n"
|
||||
"source=%s\n"
|
||||
"reason=%s\n"
|
||||
"subject=%s\n"
|
||||
"max_turns=%d\n"
|
||||
"turns_run=%d\n"
|
||||
"stall_repeat_threshold=%d\n"
|
||||
"repeated_tool_turns=%d\n"
|
||||
"current_max_tokens=%d\n"
|
||||
"possible_cause=%s\n"
|
||||
"hint=%s\n"
|
||||
"final_answer_preview=%.*s",
|
||||
source ? source : "unknown",
|
||||
reason ? reason : "unknown",
|
||||
subject ? subject : "n/a",
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
current_max_tokens,
|
||||
possible_cause ? possible_cause : "n/a",
|
||||
hint ? hint : "n/a",
|
||||
360,
|
||||
final_answer ? final_answer : "");
|
||||
|
||||
(void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, diag);
|
||||
}
|
||||
|
||||
static int agent_message_is_debounced(const char* sender_pubkey_hex, const char* message) {
|
||||
time_t now = time(NULL);
|
||||
uint64_t fp = message_fingerprint(sender_pubkey_hex, message);
|
||||
@@ -1815,7 +1881,10 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
? g_cfg->tools.trigger_max_turns
|
||||
: (g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8);
|
||||
|
||||
int turns_run = 0;
|
||||
int trigger_completed = 0;
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
turns_run = turn + 1;
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (!messages_json) {
|
||||
break;
|
||||
@@ -1836,6 +1905,7 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
}
|
||||
|
||||
if (resp.tool_call_count <= 0) {
|
||||
trigger_completed = 1;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
@@ -1871,6 +1941,20 @@ void agent_on_trigger(const char* skill_d_tag,
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
if (!trigger_completed && turns_run >= max_turns) {
|
||||
notify_admin_limit_diagnostic("trigger_skill_loop",
|
||||
"max_turns_exhausted",
|
||||
skill_d_tag,
|
||||
max_turns,
|
||||
turns_run,
|
||||
g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3,
|
||||
0,
|
||||
g_cfg->llm.max_tokens,
|
||||
NULL,
|
||||
NULL,
|
||||
"");
|
||||
}
|
||||
|
||||
if (g_trigger_manager) {
|
||||
(void)trigger_manager_fire_chains(g_trigger_manager,
|
||||
skill_d_tag,
|
||||
@@ -2097,10 +2181,17 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
cJSON_AddNumberToObject(live_user_msg, "_ts", (double)time(NULL));
|
||||
}
|
||||
|
||||
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8;
|
||||
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 20;
|
||||
int stall_repeat_threshold = g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3;
|
||||
uint64_t last_tool_fp = 0;
|
||||
int repeated_tool_turns = 0;
|
||||
int exited_on_stall = 0;
|
||||
int stall_due_to_length = 0;
|
||||
char* final_answer_owned = NULL;
|
||||
int turns_run = 0;
|
||||
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
turns_run = turn + 1;
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (!messages_json) {
|
||||
break;
|
||||
@@ -2128,6 +2219,21 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
break;
|
||||
}
|
||||
|
||||
uint64_t current_tool_fp = tool_calls_fingerprint(&resp);
|
||||
if (turn == 0 || current_tool_fp != last_tool_fp) {
|
||||
repeated_tool_turns = 1;
|
||||
last_tool_fp = current_tool_fp;
|
||||
} else {
|
||||
repeated_tool_turns++;
|
||||
}
|
||||
|
||||
if (repeated_tool_turns >= stall_repeat_threshold) {
|
||||
exited_on_stall = 1;
|
||||
stall_due_to_length = (resp.finish_reason && strcmp(resp.finish_reason, "length") == 0) ? 1 : 0;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
if (append_assistant_tool_calls_message(messages, &resp) != 0) {
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
@@ -2162,10 +2268,44 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
}
|
||||
|
||||
if (!final_answer_owned) {
|
||||
final_answer_owned = strdup("I hit my tool-use limit for this request.");
|
||||
char* messages_json = cJSON_PrintUnformatted(messages);
|
||||
if (messages_json) {
|
||||
llm_response_t final_resp;
|
||||
int final_rc = llm_chat_with_tools_messages(messages_json, tools_json, "none", &final_resp);
|
||||
free(messages_json);
|
||||
if (final_rc == 0) {
|
||||
const char* forced_answer = final_resp.content ? final_resp.content : "";
|
||||
if (forced_answer[0] != '\0') {
|
||||
final_answer_owned = strdup(forced_answer);
|
||||
}
|
||||
llm_response_free(&final_resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int exhausted_on_max_turns = (!final_answer_owned && !exited_on_stall && turns_run >= max_turns);
|
||||
|
||||
if (!final_answer_owned) {
|
||||
final_answer_owned = strdup(exited_on_stall
|
||||
? "I stopped repeated tool calls after detecting a loop and could not produce a final summary."
|
||||
: "I hit my tool-use limit for this request.");
|
||||
}
|
||||
|
||||
const char* final_answer = final_answer_owned ? final_answer_owned : "I hit my tool-use limit for this request.";
|
||||
|
||||
if (exited_on_stall || exhausted_on_max_turns) {
|
||||
notify_admin_limit_diagnostic("dm_agent_loop",
|
||||
exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted",
|
||||
sender_pubkey_hex,
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
g_cfg->llm.max_tokens,
|
||||
(exited_on_stall && stall_due_to_length) ? "max_tokens_truncation" : NULL,
|
||||
(exited_on_stall && stall_due_to_length) ? "Increase max_tokens (example: /model_set {\"max_tokens\": 4096})" : NULL,
|
||||
final_answer);
|
||||
}
|
||||
fprintf(stdout, "[didactyl] final response: %.240s%s\n",
|
||||
final_answer,
|
||||
strlen(final_answer) > 240 ? "..." : "");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
#include "config.h"
|
||||
#include "nostr_handler.h"
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
|
||||
16
src/config.c
16
src/config.c
@@ -225,6 +225,7 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
cJSON* trigger_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "trigger_max_turns");
|
||||
cJSON* api_default_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "api_default_max_turns");
|
||||
cJSON* api_max_turns_ceiling = cJSON_GetObjectItemCaseSensitive(tools, "api_max_turns_ceiling");
|
||||
cJSON* stall_repeat_threshold = cJSON_GetObjectItemCaseSensitive(tools, "stall_repeat_threshold");
|
||||
cJSON* local_http_fetch_default_timeout_seconds =
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds");
|
||||
cJSON* local_http_fetch_max_timeout_seconds =
|
||||
@@ -244,6 +245,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (api_max_turns_ceiling && cJSON_IsNumber(api_max_turns_ceiling)) {
|
||||
config->tools.api_max_turns_ceiling = (int)api_max_turns_ceiling->valuedouble;
|
||||
}
|
||||
if (stall_repeat_threshold && cJSON_IsNumber(stall_repeat_threshold)) {
|
||||
config->tools.stall_repeat_threshold = (int)stall_repeat_threshold->valuedouble;
|
||||
}
|
||||
if (local_http_fetch_default_timeout_seconds && cJSON_IsNumber(local_http_fetch_default_timeout_seconds)) {
|
||||
config->tools.local_http_fetch_default_timeout_seconds =
|
||||
(int)local_http_fetch_default_timeout_seconds->valuedouble;
|
||||
@@ -294,6 +298,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (config->tools.api_default_max_turns > config->tools.api_max_turns_ceiling) {
|
||||
config->tools.api_default_max_turns = config->tools.api_max_turns_ceiling;
|
||||
}
|
||||
if (config->tools.stall_repeat_threshold < 2) {
|
||||
config->tools.stall_repeat_threshold = 3;
|
||||
}
|
||||
if (config->tools.local_http_fetch_default_timeout_seconds < 1) {
|
||||
config->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
}
|
||||
@@ -1124,10 +1131,11 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
|
||||
config->tools.enabled = 1;
|
||||
config->tools.max_turns = 8;
|
||||
config->tools.trigger_max_turns = 6;
|
||||
config->tools.api_default_max_turns = 4;
|
||||
config->tools.api_max_turns_ceiling = 16;
|
||||
config->tools.max_turns = 20;
|
||||
config->tools.trigger_max_turns = 12;
|
||||
config->tools.api_default_max_turns = 8;
|
||||
config->tools.api_max_turns_ceiling = 32;
|
||||
config->tools.stall_repeat_threshold = 3;
|
||||
config->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
config->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
config->tools.shell.enabled = 1;
|
||||
|
||||
@@ -50,6 +50,7 @@ typedef struct {
|
||||
int trigger_max_turns;
|
||||
int api_default_max_turns;
|
||||
int api_max_turns_ceiling;
|
||||
int stall_repeat_threshold;
|
||||
int local_http_fetch_default_timeout_seconds;
|
||||
int local_http_fetch_max_timeout_seconds;
|
||||
shell_tools_config_t shell;
|
||||
|
||||
141
src/http_api.c
141
src/http_api.c
@@ -5,6 +5,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "agent.h"
|
||||
@@ -81,6 +82,82 @@ static int estimate_tokens_from_chars(int chars) {
|
||||
return (chars + 3) / 4;
|
||||
}
|
||||
|
||||
static uint64_t fnv1a64_local(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
if (!s) return h;
|
||||
while (*s) {
|
||||
h ^= (unsigned char)(*s++);
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
static uint64_t tool_calls_fingerprint_local(const llm_response_t* resp) {
|
||||
if (!resp || resp->tool_call_count <= 0 || !resp->tool_calls) {
|
||||
return 1469598103934665603ULL;
|
||||
}
|
||||
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
for (int i = 0; i < resp->tool_call_count; i++) {
|
||||
const llm_tool_call_t* tc = &resp->tool_calls[i];
|
||||
uint64_t name_h = fnv1a64_local(tc->name ? tc->name : "");
|
||||
uint64_t args_h = fnv1a64_local(tc->arguments_json ? tc->arguments_json : "{}");
|
||||
uint64_t part = name_h ^ (args_h + 0x9e3779b97f4a7c15ULL + (name_h << 6) + (name_h >> 2));
|
||||
h ^= part + (uint64_t)(i + 1) * 1099511628211ULL;
|
||||
h *= 1099511628211ULL;
|
||||
}
|
||||
|
||||
h ^= (uint64_t)resp->tool_call_count;
|
||||
h *= 1099511628211ULL;
|
||||
return h;
|
||||
}
|
||||
|
||||
static void notify_admin_limit_diagnostic_http(const char* source,
|
||||
const char* reason,
|
||||
const char* subject,
|
||||
int max_turns,
|
||||
int turns_run,
|
||||
int stall_repeat_threshold,
|
||||
int repeated_tool_turns,
|
||||
int current_max_tokens,
|
||||
const char* possible_cause,
|
||||
const char* hint,
|
||||
const char* final_answer) {
|
||||
if (!g_api_ctx.cfg || g_api_ctx.cfg->admin.pubkey[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
char diag[1800];
|
||||
snprintf(diag,
|
||||
sizeof(diag),
|
||||
"⚠️ Didactyl limit diagnostic\n"
|
||||
"source=%s\n"
|
||||
"reason=%s\n"
|
||||
"subject=%s\n"
|
||||
"max_turns=%d\n"
|
||||
"turns_run=%d\n"
|
||||
"stall_repeat_threshold=%d\n"
|
||||
"repeated_tool_turns=%d\n"
|
||||
"current_max_tokens=%d\n"
|
||||
"possible_cause=%s\n"
|
||||
"hint=%s\n"
|
||||
"final_answer_preview=%.*s",
|
||||
source ? source : "unknown",
|
||||
reason ? reason : "unknown",
|
||||
subject ? subject : "n/a",
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
current_max_tokens,
|
||||
possible_cause ? possible_cause : "n/a",
|
||||
hint ? hint : "n/a",
|
||||
360,
|
||||
final_answer ? final_answer : "");
|
||||
|
||||
(void)nostr_handler_send_dm_auto(g_api_ctx.cfg->admin.pubkey, diag);
|
||||
}
|
||||
|
||||
static int uri_extract_after_prefix(const struct mg_str* uri,
|
||||
const char* prefix,
|
||||
char* out,
|
||||
@@ -844,9 +921,20 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int stall_repeat_threshold = 3;
|
||||
if (g_api_ctx.cfg && g_api_ctx.cfg->tools.stall_repeat_threshold > 1) {
|
||||
stall_repeat_threshold = g_api_ctx.cfg->tools.stall_repeat_threshold;
|
||||
}
|
||||
|
||||
char* final_response = NULL;
|
||||
uint64_t last_tool_fp = 0;
|
||||
int repeated_tool_turns = 0;
|
||||
int exited_on_stall = 0;
|
||||
int stall_due_to_length = 0;
|
||||
int turns_run = 0;
|
||||
|
||||
for (int turn = 0; turn < max_turns; turn++) {
|
||||
turns_run = turn + 1;
|
||||
char* messages_json = cJSON_PrintUnformatted(convo);
|
||||
if (!messages_json) break;
|
||||
|
||||
@@ -877,6 +965,21 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
break;
|
||||
}
|
||||
|
||||
uint64_t current_tool_fp = tool_calls_fingerprint_local(&resp);
|
||||
if (turn == 0 || current_tool_fp != last_tool_fp) {
|
||||
repeated_tool_turns = 1;
|
||||
last_tool_fp = current_tool_fp;
|
||||
} else {
|
||||
repeated_tool_turns++;
|
||||
}
|
||||
|
||||
if (repeated_tool_turns >= stall_repeat_threshold) {
|
||||
exited_on_stall = 1;
|
||||
stall_due_to_length = (resp.finish_reason && strcmp(resp.finish_reason, "length") == 0) ? 1 : 0;
|
||||
llm_response_free(&resp);
|
||||
break;
|
||||
}
|
||||
|
||||
if (append_assistant_tool_calls_message_local(convo, &resp) != 0) {
|
||||
llm_response_free(&resp);
|
||||
final_response = strdup("Failed to append assistant tool calls.");
|
||||
@@ -917,9 +1020,43 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo,
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
done:
|
||||
done:;
|
||||
int hit_max_turn_limit = (!final_response && !exited_on_stall && turns_run >= max_turns);
|
||||
|
||||
if (!final_response) {
|
||||
final_response = strdup(tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run.");
|
||||
char* messages_json = cJSON_PrintUnformatted(convo);
|
||||
if (messages_json) {
|
||||
llm_response_t final_resp;
|
||||
int final_rc = llm_chat_with_tools_messages(messages_json, tools_json, "none", &final_resp);
|
||||
free(messages_json);
|
||||
if (final_rc == 0) {
|
||||
const char* forced_answer = final_resp.content ? final_resp.content : "";
|
||||
if (forced_answer[0] != '\0') {
|
||||
final_response = strdup(forced_answer);
|
||||
}
|
||||
llm_response_free(&final_resp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!final_response) {
|
||||
final_response = strdup(exited_on_stall
|
||||
? "Stopped repeated tool calls after detecting a loop, and no final summary was produced."
|
||||
: (tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run."));
|
||||
}
|
||||
|
||||
if (exited_on_stall || hit_max_turn_limit) {
|
||||
notify_admin_limit_diagnostic_http("http_api_tool_loop",
|
||||
exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted",
|
||||
log_sender,
|
||||
max_turns,
|
||||
turns_run,
|
||||
stall_repeat_threshold,
|
||||
repeated_tool_turns,
|
||||
g_api_ctx.cfg ? g_api_ctx.cfg->llm.max_tokens : 0,
|
||||
(exited_on_stall && stall_due_to_length) ? "max_tokens_truncation" : NULL,
|
||||
(exited_on_stall && stall_due_to_length) ? "Increase max_tokens (example: /model_set {\"max_tokens\": 4096})" : NULL,
|
||||
final_response ? final_response : "");
|
||||
}
|
||||
|
||||
cJSON_AddBoolToObject(root, "success", 1);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#define DIDACTYL_HTTP_API_H
|
||||
|
||||
#include "config.h"
|
||||
#include "tools.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
|
||||
@@ -289,6 +289,11 @@ static int parse_llm_response(const char* json, llm_response_t* out) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* finish_reason = first ? cJSON_GetObjectItemCaseSensitive(first, "finish_reason") : NULL;
|
||||
if (finish_reason && cJSON_IsString(finish_reason) && finish_reason->valuestring) {
|
||||
out->finish_reason = strdup(finish_reason->valuestring);
|
||||
}
|
||||
|
||||
cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content");
|
||||
if (content && cJSON_IsString(content) && content->valuestring) {
|
||||
out->content = strdup(content->valuestring);
|
||||
@@ -459,6 +464,7 @@ int llm_chat_with_tools(const char* system_prompt,
|
||||
void llm_response_free(llm_response_t* response) {
|
||||
if (!response) return;
|
||||
free(response->content);
|
||||
free(response->finish_reason);
|
||||
for (int i = 0; i < response->tool_call_count; i++) {
|
||||
free(response->tool_calls[i].id);
|
||||
free(response->tool_calls[i].name);
|
||||
|
||||
@@ -13,6 +13,7 @@ typedef struct {
|
||||
char* content;
|
||||
llm_tool_call_t* tool_calls;
|
||||
int tool_call_count;
|
||||
char* finish_reason;
|
||||
} llm_response_t;
|
||||
|
||||
int llm_init(const llm_config_t* config);
|
||||
|
||||
56
src/main.c
56
src/main.c
@@ -75,6 +75,8 @@ static void print_usage(const char* prog) {
|
||||
" If file is missing, startup falls back to minimal nsec-only mode.\n"
|
||||
" --nsec <nsec_or_hex>\n"
|
||||
" Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC.\n"
|
||||
" --admin <npub_or_hex>\n"
|
||||
" Administrator pubkey override; accepts npub1... or 64-char hex.\n"
|
||||
" --api-port <port>\n"
|
||||
" Enable HTTP API and bind to this port (1-65535).\n"
|
||||
" --api-bind <addr>\n"
|
||||
@@ -863,24 +865,32 @@ static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
|
||||
free(llm_plaintext);
|
||||
}
|
||||
|
||||
if (!admin_config_is_complete(cfg)) {
|
||||
char* agent_plaintext = NULL;
|
||||
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
|
||||
if (apply_recalled_agent_config(cfg, agent_plaintext) == 0) {
|
||||
char* agent_plaintext = NULL;
|
||||
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
|
||||
didactyl_config_t recalled = *cfg;
|
||||
if (apply_recalled_agent_config(&recalled, agent_plaintext) == 0) {
|
||||
if (!admin_config_is_complete(cfg)) {
|
||||
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", recalled.admin.pubkey);
|
||||
cfg->dm_protocol = recalled.dm_protocol;
|
||||
DEBUG_INFO("[didactyl] startup phase: recovered agent_config from Nostr");
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled agent_config");
|
||||
} else if (recalled.admin.pubkey[0] != '\0' && strcmp(cfg->admin.pubkey, recalled.admin.pubkey) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: agent_config admin pubkey differs from loaded config (loaded=%.16s..., nostr=%.16s...); loaded config wins",
|
||||
cfg->admin.pubkey,
|
||||
recalled.admin.pubkey);
|
||||
}
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled agent_config");
|
||||
}
|
||||
free(agent_plaintext);
|
||||
} else {
|
||||
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
|
||||
}
|
||||
free(agent_plaintext);
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const char* config_path = "./genesis.jsonc";
|
||||
const char* cli_nsec = NULL;
|
||||
const char* cli_admin_pubkey = NULL;
|
||||
const char* api_bind_override = NULL;
|
||||
int api_port_override = 0;
|
||||
int debug_level = DEBUG_LEVEL_NONE;
|
||||
@@ -944,6 +954,8 @@ int main(int argc, char** argv) {
|
||||
debug_level = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--nsec") == 0 && i + 1 < argc) {
|
||||
cli_nsec = argv[++i];
|
||||
} else if (strcmp(argv[i], "--admin") == 0 && i + 1 < argc) {
|
||||
cli_admin_pubkey = argv[++i];
|
||||
} else if (strcmp(argv[i], "--api-port") == 0 && i + 1 < argc) {
|
||||
api_port_override = atoi(argv[++i]);
|
||||
} else if (strcmp(argv[i], "--api-bind") == 0 && i + 1 < argc) {
|
||||
@@ -997,6 +1009,18 @@ int main(int argc, char** argv) {
|
||||
}
|
||||
}
|
||||
|
||||
if (cli_admin_pubkey && cli_admin_pubkey[0] != '\0') {
|
||||
char decoded_admin[65] = {0};
|
||||
if (decode_pubkey_hex_or_npub_local(cli_admin_pubkey, decoded_admin) != 0) {
|
||||
fprintf(stderr, "Invalid admin pubkey provided via --admin (expected npub1... or 64-char hex)\n");
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
return 1;
|
||||
}
|
||||
snprintf(cfg.admin.pubkey, sizeof(cfg.admin.pubkey), "%s", decoded_admin);
|
||||
DEBUG_INFO("[didactyl] startup phase: admin override applied from --admin (%.16s...)", cfg.admin.pubkey);
|
||||
}
|
||||
|
||||
if (config_ensure_default_skill_startup_events(&cfg) != 0) {
|
||||
fprintf(stderr, "Failed to synthesize startup events from default_skill\n");
|
||||
config_free(&cfg);
|
||||
@@ -1124,7 +1148,7 @@ int main(int argc, char** argv) {
|
||||
if (!admin_config_is_complete(&cfg)) {
|
||||
startup_step_fail(4, "Validate admin config", "missing admin pubkey");
|
||||
fprintf(stderr,
|
||||
"Missing admin config: provide admin.pubkey in genesis or store d=agent_config via config_store\n");
|
||||
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n");
|
||||
nostr_handler_cleanup();
|
||||
config_free(&cfg);
|
||||
nostr_cleanup();
|
||||
@@ -1153,17 +1177,19 @@ int main(int argc, char** argv) {
|
||||
if (nostr_handler_reconcile_startup_events() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: reconcile startup events failed (continuing)");
|
||||
}
|
||||
if (persist_runtime_config_to_nostr(&cfg) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
|
||||
}
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping bootstrap publish on subsequent run");
|
||||
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run");
|
||||
if (nostr_handler_load_system_context_from_adopted_skills() != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to load system context from adopted skills (continuing with fallback)");
|
||||
}
|
||||
}
|
||||
|
||||
if (persist_runtime_config_to_nostr(&cfg) != 0) {
|
||||
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
|
||||
} else {
|
||||
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
|
||||
}
|
||||
|
||||
startup_step_ok(7, "Reconcile/persist startup state", NULL);
|
||||
|
||||
const char* system_context = nostr_handler_get_system_context();
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||
#define DIDACTYL_VERSION_MAJOR 0
|
||||
#define DIDACTYL_VERSION_MINOR 0
|
||||
#define DIDACTYL_VERSION_PATCH 77
|
||||
#define DIDACTYL_VERSION "v0.0.77"
|
||||
#define DIDACTYL_VERSION_PATCH 82
|
||||
#define DIDACTYL_VERSION "v0.0.82"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -416,6 +416,7 @@ static int managed_subscription_register_locked(managed_subscription_id_t id,
|
||||
static int managed_subscriptions_resubscribe_all_locked(void);
|
||||
static int managed_subscription_id_from_name(const char* name, managed_subscription_id_t* out_id);
|
||||
static int apply_runtime_kind10002_relays(cJSON* tags, const char* source_label);
|
||||
static void seed_default_skill_into_cache(void);
|
||||
|
||||
static int relay_list_contains_local(char** relays, int relay_count, const char* relay_url) {
|
||||
if (!relays || relay_count <= 0 || !relay_url || relay_url[0] == '\0') {
|
||||
@@ -1249,6 +1250,50 @@ static void self_skill_cache_upsert_event_locked(cJSON* event) {
|
||||
cJSON_AddItemToArray(g_self_skill_events, dup);
|
||||
}
|
||||
|
||||
static void seed_default_skill_into_cache(void) {
|
||||
if (!g_cfg) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->default_skill.kind != 31123 && g_cfg->default_skill.kind != 31124) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (g_cfg->keys.public_key_hex[0] == '\0' ||
|
||||
!g_cfg->default_skill.content || g_cfg->default_skill.content[0] == '\0' ||
|
||||
!g_cfg->default_skill.tags_json || g_cfg->default_skill.tags_json[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_Parse(g_cfg->default_skill.tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
return;
|
||||
}
|
||||
|
||||
cJSON* event = cJSON_CreateObject();
|
||||
if (!event) {
|
||||
cJSON_Delete(tags);
|
||||
return;
|
||||
}
|
||||
|
||||
const double now = (double)time(NULL);
|
||||
cJSON_AddNumberToObject(event, "kind", g_cfg->default_skill.kind);
|
||||
cJSON_AddStringToObject(event, "pubkey", g_cfg->keys.public_key_hex);
|
||||
cJSON_AddStringToObject(event, "content", g_cfg->default_skill.content);
|
||||
cJSON_AddItemToObject(event, "tags", tags);
|
||||
cJSON_AddNumberToObject(event, "created_at", now);
|
||||
cJSON_AddStringToObject(event,
|
||||
"id",
|
||||
"0000000000000000000000000000000000000000000000000000000000000000");
|
||||
|
||||
pthread_mutex_lock(&g_self_skill_mutex);
|
||||
self_skill_cache_upsert_event_locked(event);
|
||||
pthread_mutex_unlock(&g_self_skill_mutex);
|
||||
|
||||
cJSON_Delete(event);
|
||||
}
|
||||
|
||||
static int hex_to_pubkey(const char* hex, unsigned char out_pubkey[32]) {
|
||||
if (!hex || !out_pubkey || strlen(hex) != 64U) {
|
||||
return -1;
|
||||
@@ -2969,6 +3014,8 @@ int nostr_handler_reconcile_startup_events(void) {
|
||||
publish_pending_startup_events_for_relay_index(relay_index, "startup_reconcile");
|
||||
}
|
||||
|
||||
seed_default_skill_into_cache();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
#define DIDACTYL_PROMPT_TEMPLATE_H
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "tools.h"
|
||||
#include "tools/tools.h"
|
||||
|
||||
#define PROMPT_TEMPLATE_MAX_SECTIONS 32
|
||||
#define PROMPT_TEMPLATE_MAX_NAME_LEN 64
|
||||
|
||||
@@ -271,10 +271,11 @@ static void config_set_defaults(didactyl_config_t* cfg) {
|
||||
cfg->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
|
||||
cfg->tools.enabled = 1;
|
||||
cfg->tools.max_turns = 8;
|
||||
cfg->tools.trigger_max_turns = 6;
|
||||
cfg->tools.api_default_max_turns = 4;
|
||||
cfg->tools.api_max_turns_ceiling = 16;
|
||||
cfg->tools.max_turns = 20;
|
||||
cfg->tools.trigger_max_turns = 12;
|
||||
cfg->tools.api_default_max_turns = 8;
|
||||
cfg->tools.api_max_turns_ceiling = 32;
|
||||
cfg->tools.stall_repeat_threshold = 3;
|
||||
cfg->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
cfg->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
cfg->tools.shell.enabled = 1;
|
||||
@@ -801,6 +802,422 @@ static int recover_existing_config_from_nostr(didactyl_config_t* cfg, int* out_r
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int query_self_kind0_name(const didactyl_config_t* cfg,
|
||||
char* out_name,
|
||||
size_t out_name_size,
|
||||
int* out_found) {
|
||||
if (!cfg || !out_name || out_name_size == 0 || !out_found || cfg->keys.public_key_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out_name[0] = '\0';
|
||||
*out_found = 0;
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 3000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
|
||||
cJSON_Delete(arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* ev = cJSON_GetArrayItem(arr, 0);
|
||||
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
|
||||
cJSON_Delete(arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* profile = cJSON_Parse(content->valuestring);
|
||||
if (!profile || !cJSON_IsObject(profile)) {
|
||||
cJSON_Delete(profile);
|
||||
cJSON_Delete(arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(profile, "display_name");
|
||||
cJSON* name = cJSON_GetObjectItemCaseSensitive(profile, "name");
|
||||
cJSON* chosen = NULL;
|
||||
|
||||
if (display_name && cJSON_IsString(display_name) && display_name->valuestring && display_name->valuestring[0] != '\0') {
|
||||
chosen = display_name;
|
||||
} else if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
|
||||
chosen = name;
|
||||
}
|
||||
|
||||
if (chosen && chosen->valuestring) {
|
||||
snprintf(out_name, out_name_size, "%s", chosen->valuestring);
|
||||
*out_found = 1;
|
||||
}
|
||||
|
||||
cJSON_Delete(profile);
|
||||
cJSON_Delete(arr);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, const char* d_tag, char** out_plaintext) {
|
||||
if (!cfg || !d_tag || !out_plaintext || cfg->keys.public_key_hex[0] == '\0') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_plaintext = NULL;
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
cJSON* d_vals = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors || !d_vals) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
cJSON_Delete(d_vals);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(30078));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddItemToArray(d_vals, cJSON_CreateString(d_tag));
|
||||
cJSON_AddItemToObject(filter, "#d", d_vals);
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
|
||||
cJSON_Delete(arr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* ev = cJSON_GetArrayItem(arr, 0);
|
||||
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
|
||||
cJSON_Delete(arr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t out_cap = strlen(content->valuestring) + 1024U;
|
||||
char* plaintext = (char*)malloc(out_cap);
|
||||
if (!plaintext) {
|
||||
cJSON_Delete(arr);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int dec_rc = nostr_nip44_decrypt(cfg->keys.private_key,
|
||||
cfg->keys.public_key,
|
||||
content->valuestring,
|
||||
plaintext,
|
||||
out_cap);
|
||||
cJSON_Delete(arr);
|
||||
if (dec_rc != NOSTR_SUCCESS) {
|
||||
free(plaintext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_plaintext = plaintext;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char* plaintext) {
|
||||
if (!cfg || !plaintext) return -1;
|
||||
|
||||
cJSON* root = cJSON_Parse(plaintext);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider");
|
||||
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key");
|
||||
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model");
|
||||
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url");
|
||||
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens");
|
||||
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature");
|
||||
|
||||
if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' ||
|
||||
!model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' ||
|
||||
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0') {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (provider && cJSON_IsString(provider) && provider->valuestring && provider->valuestring[0] != '\0') {
|
||||
snprintf(cfg->llm.provider, sizeof(cfg->llm.provider), "%s", provider->valuestring);
|
||||
} else if (cfg->llm.provider[0] == '\0') {
|
||||
snprintf(cfg->llm.provider, sizeof(cfg->llm.provider), "%s", "openai");
|
||||
}
|
||||
|
||||
snprintf(cfg->llm.api_key, sizeof(cfg->llm.api_key), "%s", api_key->valuestring);
|
||||
snprintf(cfg->llm.model, sizeof(cfg->llm.model), "%s", model->valuestring);
|
||||
snprintf(cfg->llm.base_url, sizeof(cfg->llm.base_url), "%s", base_url->valuestring);
|
||||
if (max_tokens && cJSON_IsNumber(max_tokens)) {
|
||||
cfg->llm.max_tokens = (int)max_tokens->valuedouble;
|
||||
}
|
||||
if (temperature && cJSON_IsNumber(temperature)) {
|
||||
cfg->llm.temperature = temperature->valuedouble;
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int apply_recalled_agent_config_wizard(didactyl_config_t* cfg, const char* plaintext) {
|
||||
if (!cfg || !plaintext) return -1;
|
||||
|
||||
cJSON* root = cJSON_Parse(plaintext);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(root, "admin_pubkey");
|
||||
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
|
||||
|
||||
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
|
||||
char decoded[65] = {0};
|
||||
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) {
|
||||
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
|
||||
}
|
||||
}
|
||||
|
||||
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') {
|
||||
if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
|
||||
cfg->dm_protocol = DM_PROTOCOL_NIP17;
|
||||
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
|
||||
cfg->dm_protocol = DM_PROTOCOL_BOTH;
|
||||
} else {
|
||||
cfg->dm_protocol = DM_PROTOCOL_NIP04;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(root);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int recover_full_config_from_nostr(didactyl_config_t* cfg,
|
||||
char* out_agent_name,
|
||||
size_t out_agent_name_size,
|
||||
int* out_agent_name_found) {
|
||||
if (!cfg || !out_agent_name || out_agent_name_size == 0 || !out_agent_name_found) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
out_agent_name[0] = '\0';
|
||||
*out_agent_name_found = 0;
|
||||
|
||||
didactyl_config_t tmp = *cfg;
|
||||
if (nostr_handler_init(&tmp) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
(void)wait_connected_relays_ms(5000);
|
||||
|
||||
(void)query_self_kind0_name(cfg, out_agent_name, out_agent_name_size, out_agent_name_found);
|
||||
|
||||
char* llm_plaintext = NULL;
|
||||
if (fetch_and_decrypt_self_config_wizard(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
|
||||
(void)apply_recalled_llm_config_wizard(cfg, llm_plaintext);
|
||||
}
|
||||
free(llm_plaintext);
|
||||
|
||||
char* agent_plaintext = NULL;
|
||||
if (fetch_and_decrypt_self_config_wizard(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
|
||||
(void)apply_recalled_agent_config_wizard(cfg, agent_plaintext);
|
||||
}
|
||||
free(agent_plaintext);
|
||||
|
||||
nostr_handler_cleanup();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const char* dm_protocol_to_string_local(dm_protocol_t protocol) {
|
||||
switch (protocol) {
|
||||
case DM_PROTOCOL_NIP17: return "nip17";
|
||||
case DM_PROTOCOL_BOTH: return "both";
|
||||
case DM_PROTOCOL_NIP04:
|
||||
default:
|
||||
return "nip04";
|
||||
}
|
||||
}
|
||||
|
||||
static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg,
|
||||
const char* d_tag,
|
||||
const char* content_json) {
|
||||
if (!cfg || !d_tag || !content_json || d_tag[0] == '\0') return -1;
|
||||
|
||||
size_t cipher_cap = (strlen(content_json) * 4U) + 1024U;
|
||||
char* ciphertext = (char*)malloc(cipher_cap);
|
||||
if (!ciphertext) return -1;
|
||||
|
||||
int enc_rc = nostr_nip44_encrypt(cfg->keys.private_key,
|
||||
cfg->keys.public_key,
|
||||
content_json,
|
||||
ciphertext,
|
||||
cipher_cap);
|
||||
if (enc_rc != NOSTR_SUCCESS) {
|
||||
free(ciphertext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
cJSON* d = cJSON_CreateArray();
|
||||
cJSON* app = cJSON_CreateArray();
|
||||
if (!tags || !d || !app) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(d);
|
||||
cJSON_Delete(app);
|
||||
free(ciphertext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(d, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d, cJSON_CreateString(d_tag));
|
||||
cJSON_AddItemToArray(tags, d);
|
||||
|
||||
cJSON_AddItemToArray(app, cJSON_CreateString("app"));
|
||||
cJSON_AddItemToArray(app, cJSON_CreateString("didactyl"));
|
||||
cJSON_AddItemToArray(tags, app);
|
||||
|
||||
nostr_publish_result_t result;
|
||||
memset(&result, 0, sizeof(result));
|
||||
int rc = nostr_handler_publish_kind_event(30078, ciphertext, tags, &result);
|
||||
nostr_handler_publish_result_free(&result);
|
||||
cJSON_Delete(tags);
|
||||
free(ciphertext);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) {
|
||||
if (!cfg) return -1;
|
||||
|
||||
cJSON* llm = cJSON_CreateObject();
|
||||
if (!llm) return -1;
|
||||
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
|
||||
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
|
||||
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
|
||||
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
|
||||
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
|
||||
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
|
||||
|
||||
char* llm_json = cJSON_PrintUnformatted(llm);
|
||||
cJSON_Delete(llm);
|
||||
if (!llm_json) return -1;
|
||||
|
||||
int llm_rc = publish_encrypted_self_config_wizard(cfg, "llm_config", llm_json);
|
||||
free(llm_json);
|
||||
|
||||
cJSON* agent = cJSON_CreateObject();
|
||||
if (!agent) return llm_rc;
|
||||
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey);
|
||||
cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol));
|
||||
char* agent_json = cJSON_PrintUnformatted(agent);
|
||||
cJSON_Delete(agent);
|
||||
if (!agent_json) return llm_rc;
|
||||
|
||||
int agent_rc = publish_encrypted_self_config_wizard(cfg, "agent_config", agent_json);
|
||||
free(agent_json);
|
||||
|
||||
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1;
|
||||
}
|
||||
|
||||
static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg) {
|
||||
if (!cfg) return -1;
|
||||
|
||||
didactyl_config_t tmp = *cfg;
|
||||
if (nostr_handler_init(&tmp) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
(void)wait_connected_relays_ms(5000);
|
||||
int rc = persist_runtime_config_to_nostr_wizard(cfg);
|
||||
|
||||
/* Give relays time to acknowledge the published events before tearing
|
||||
down the connection. Without this, the fire-and-forget publish may
|
||||
not reach any relay before cleanup closes the sockets. */
|
||||
for (int i = 0; i < 30; i++) {
|
||||
nostr_handler_poll(100);
|
||||
}
|
||||
|
||||
nostr_handler_cleanup();
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int publish_kind10002_relays_wizard_online(const didactyl_config_t* cfg) {
|
||||
if (!cfg) return -1;
|
||||
|
||||
didactyl_config_t tmp = *cfg;
|
||||
if (nostr_handler_init(&tmp) != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
(void)wait_connected_relays_ms(5000);
|
||||
|
||||
char* tags_json = NULL;
|
||||
if (build_kind10002_tags_json(cfg, &tags_json) != 0 || !tags_json) {
|
||||
free(tags_json);
|
||||
nostr_handler_cleanup();
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* tags = cJSON_Parse(tags_json);
|
||||
free(tags_json);
|
||||
if (!tags || !cJSON_IsArray(tags)) {
|
||||
cJSON_Delete(tags);
|
||||
nostr_handler_cleanup();
|
||||
return -1;
|
||||
}
|
||||
|
||||
nostr_publish_result_t result;
|
||||
memset(&result, 0, sizeof(result));
|
||||
int rc = nostr_handler_publish_kind_event(10002, "", tags, &result);
|
||||
nostr_handler_publish_result_free(&result);
|
||||
cJSON_Delete(tags);
|
||||
|
||||
nostr_handler_cleanup();
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int ensure_startup_kind10002_from_current_relays(didactyl_config_t* cfg) {
|
||||
if (!cfg) return -1;
|
||||
|
||||
char* tags_json = NULL;
|
||||
if (build_kind10002_tags_json(cfg, &tags_json) != 0 || !tags_json) {
|
||||
free(tags_json);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int rc = upsert_startup_event(cfg, 10002, "", tags_json);
|
||||
free(tags_json);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int wizard_llm_test(const llm_config_t* llm_cfg) {
|
||||
if (!llm_cfg) return -1;
|
||||
if (llm_init(llm_cfg) != 0) {
|
||||
@@ -1037,10 +1454,12 @@ static int prompt_model_selection(const cJSON* models,
|
||||
}
|
||||
}
|
||||
|
||||
static int prompt_admin_pubkey(didactyl_config_t* cfg) {
|
||||
static int prompt_admin_pubkey_with_header(didactyl_config_t* cfg,
|
||||
const char* step_title,
|
||||
const char* section_title) {
|
||||
char input[WIZARD_LINE_MAX];
|
||||
for (;;) {
|
||||
render_wizard_page_header("Step 3 of 7", "New Agent Setup -- Administrator");
|
||||
render_wizard_page_header(step_title, section_title);
|
||||
if (read_line_prompt(" Enter the admin's Nostr public key (npub1... or hex):\n> ", input, sizeof(input)) != 0) {
|
||||
return -1;
|
||||
}
|
||||
@@ -1056,9 +1475,15 @@ static int prompt_admin_pubkey(didactyl_config_t* cfg) {
|
||||
}
|
||||
}
|
||||
|
||||
static int prompt_llm_config(didactyl_config_t* cfg) {
|
||||
static int prompt_admin_pubkey(didactyl_config_t* cfg) {
|
||||
return prompt_admin_pubkey_with_header(cfg, "Step 3 of 7", "New Agent Setup -- Administrator");
|
||||
}
|
||||
|
||||
static int prompt_llm_config_with_header(didactyl_config_t* cfg,
|
||||
const char* step_title,
|
||||
const char* section_title) {
|
||||
for (;;) {
|
||||
render_wizard_page_header("Step 5 of 7", "New Agent Setup -- LLM Provider");
|
||||
render_wizard_page_header(step_title, section_title);
|
||||
print_option('1', " ppq.ai");
|
||||
print_option('2', " Routstr");
|
||||
print_option('3', " OpenAI-compatible");
|
||||
@@ -1179,11 +1604,17 @@ static int prompt_llm_config(didactyl_config_t* cfg) {
|
||||
}
|
||||
}
|
||||
|
||||
static int prompt_relay_configuration(didactyl_config_t* cfg) {
|
||||
static int prompt_llm_config(didactyl_config_t* cfg) {
|
||||
return prompt_llm_config_with_header(cfg, "Step 5 of 7", "New Agent Setup -- LLM Provider");
|
||||
}
|
||||
|
||||
static int prompt_relay_configuration_with_header(didactyl_config_t* cfg,
|
||||
const char* step_title,
|
||||
const char* section_title) {
|
||||
char input[WIZARD_LINE_MAX];
|
||||
|
||||
for (;;) {
|
||||
render_wizard_page_header("Step 6 of 7", "New Agent Setup -- Relay Configuration");
|
||||
render_wizard_page_header(step_title, section_title);
|
||||
fprintf(stderr, " Current relays:\n");
|
||||
for (int i = 0; i < cfg->relay_count; i++) {
|
||||
fprintf(stderr, " %d. %s\n", i + 1, cfg->relays[i] ? cfg->relays[i] : "");
|
||||
@@ -1235,6 +1666,10 @@ static int prompt_relay_configuration(didactyl_config_t* cfg) {
|
||||
}
|
||||
}
|
||||
|
||||
static int prompt_relay_configuration(didactyl_config_t* cfg) {
|
||||
return prompt_relay_configuration_with_header(cfg, "Step 6 of 7", "New Agent Setup -- Relay Configuration");
|
||||
}
|
||||
|
||||
static int run_command_local(char* const argv[]) {
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) return -1;
|
||||
@@ -1749,6 +2184,13 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t
|
||||
if (c == 's') return 2;
|
||||
|
||||
if (c == 'b') {
|
||||
/* Publish kind 30078 agent_config + llm_config so the --nsec-only
|
||||
restart path can recover them. Non-fatal: main() will also
|
||||
publish during bootstrap, but doing it here is belt-and-suspenders. */
|
||||
if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) {
|
||||
fprintf(stderr, "%sWarning: failed to publish runtime config to Nostr (will retry on boot).%s\n",
|
||||
ANSI_YELLOW, ANSI_RESET);
|
||||
}
|
||||
fprintf(stderr, "%sStep 7 of 7 -- Booting new agent. Check your messages for initial greeting.%s\n",
|
||||
ANSI_YELLOW,
|
||||
ANSI_RESET);
|
||||
@@ -1756,6 +2198,16 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t
|
||||
}
|
||||
|
||||
if (c == 'i') {
|
||||
/* Publish kind 30078 agent_config + llm_config BEFORE installing the
|
||||
service. The systemd service starts with --nsec only and depends
|
||||
on recovering these from relays. */
|
||||
if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) {
|
||||
fprintf(stderr,
|
||||
"%sFailed to publish runtime config to Nostr; refusing install to avoid missing admin/LLM on first boot.%s\n",
|
||||
ANSI_RED, ANSI_RESET);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) {
|
||||
fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET);
|
||||
return -1;
|
||||
@@ -1794,6 +2246,10 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t
|
||||
|
||||
static int existing_agent_flow(didactyl_config_t* cfg) {
|
||||
char nsec_in[OW_MAX_KEY_LEN] = {0};
|
||||
char agent_name[OW_MAX_NAME_LEN] = {0};
|
||||
int agent_name_found = 0;
|
||||
int relay_changed = 0;
|
||||
|
||||
render_wizard_page_header("Existing Agent", "Identity");
|
||||
if (read_secret_prompt("Enter your agent nsec (nsec1... or 64-char hex): ", nsec_in, sizeof(nsec_in)) != 0) return -1;
|
||||
if (line_is_quit(nsec_in)) return -1;
|
||||
@@ -1807,13 +2263,183 @@ static int existing_agent_flow(didactyl_config_t* cfg) {
|
||||
render_wizard_page_header("Existing Agent", "Config Recovery");
|
||||
fprintf(stderr, " Relay list (kind 10002): NOT FOUND\n\n");
|
||||
fprintf(stderr, "%sUnable to find a kind 10002 relay list for this identity.%s\n", ANSI_RED, ANSI_RESET);
|
||||
fprintf(stderr, "\n");
|
||||
print_option('n', "ew agent setup with this identity");
|
||||
print_option('q', "uit to main menu");
|
||||
wizard_option_t opts[] = {{'n', ""}, {'q', ""}};
|
||||
char c = read_menu_choice(opts, 2);
|
||||
if (c == 'n') {
|
||||
int rc = new_agent_flow(cfg, NULL, 0);
|
||||
if (rc == 0) return SETUP_WIZARD_RC_BOOTSTRAP;
|
||||
if (rc == 1) return SETUP_WIZARD_RC_EXIT;
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
render_wizard_page_header("Existing Agent", "Config Recovery");
|
||||
fprintf(stderr, " Relay list (kind 10002): FOUND (%d relays)\n\n", cfg->relay_count);
|
||||
if (recover_full_config_from_nostr(cfg, agent_name, sizeof(agent_name), &agent_name_found) != 0) {
|
||||
fprintf(stderr, "%sWarning: unable to recover full config from Nostr, continuing with partial recovery.%s\n",
|
||||
ANSI_YELLOW,
|
||||
ANSI_RESET);
|
||||
}
|
||||
|
||||
return 0;
|
||||
if (!agent_name_found || agent_name[0] == '\0') {
|
||||
snprintf(agent_name, sizeof(agent_name), "%s", "Didactyl");
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
char masked_key[64] = {0};
|
||||
size_t api_len = strlen(cfg->llm.api_key);
|
||||
if (api_len >= 8U) {
|
||||
snprintf(masked_key,
|
||||
sizeof(masked_key),
|
||||
"%.4s...%s",
|
||||
cfg->llm.api_key,
|
||||
cfg->llm.api_key + api_len - 4U);
|
||||
} else if (api_len > 0U) {
|
||||
snprintf(masked_key, sizeof(masked_key), "****");
|
||||
} else {
|
||||
snprintf(masked_key, sizeof(masked_key), "(not set)");
|
||||
}
|
||||
|
||||
render_wizard_page_header("Existing Agent", "Recovered Configuration");
|
||||
fprintf(stderr, "Your agent was found. Change any of the following:\n\n");
|
||||
fprintf(stderr, " Name: %s\n", agent_name);
|
||||
fprintf(stderr, " Identity: %.16s...\n", cfg->keys.public_key_hex);
|
||||
fprintf(stderr, " Admin: %s\n", cfg->admin.pubkey[0] ? cfg->admin.pubkey : "(not set)");
|
||||
fprintf(stderr, " LLM Provider: %s\n", cfg->llm.provider[0] ? cfg->llm.provider : "(not set)");
|
||||
fprintf(stderr, " LLM Model: %s\n", cfg->llm.model[0] ? cfg->llm.model : "(not set)");
|
||||
fprintf(stderr, " LLM Base URL: %s\n", cfg->llm.base_url[0] ? cfg->llm.base_url : "(not set)");
|
||||
fprintf(stderr, " LLM API Key: %s\n", masked_key);
|
||||
fprintf(stderr, " Relays: %d configured\n", cfg->relay_count);
|
||||
fprintf(stderr, "\n");
|
||||
|
||||
print_option('a', "dmin pubkey");
|
||||
print_option('l', "lm config");
|
||||
print_option('r', "elay configuration");
|
||||
print_option('c', "ontinue to launch options");
|
||||
print_option('q', "uit");
|
||||
|
||||
wizard_option_t opts[] = {{'a', ""}, {'l', ""}, {'r', ""}, {'c', ""}, {'q', ""}};
|
||||
char c = read_menu_choice(opts, 5);
|
||||
if (c == 'q') {
|
||||
return -1;
|
||||
}
|
||||
if (c == 'c') {
|
||||
break;
|
||||
}
|
||||
if (c == 'a') {
|
||||
if (prompt_admin_pubkey_with_header(cfg,
|
||||
"Existing Agent -- Review",
|
||||
"Existing Agent -- Administrator") != 0) {
|
||||
return -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == 'l') {
|
||||
if (prompt_llm_config_with_header(cfg,
|
||||
"Existing Agent -- Review",
|
||||
"Existing Agent -- LLM Provider") != 0) {
|
||||
return -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c == 'r') {
|
||||
for (;;) {
|
||||
int rc = prompt_relay_configuration_with_header(cfg,
|
||||
"Existing Agent -- Review",
|
||||
"Existing Agent -- Relay Configuration");
|
||||
if (rc < 0) return -1;
|
||||
if (rc == 1) break;
|
||||
relay_changed = 1;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
render_wizard_page_header("Existing Agent", "Launch Options");
|
||||
fprintf(stderr, " Name: %s\n", agent_name);
|
||||
fprintf(stderr, " Identity: %.16s...\n", cfg->keys.public_key_hex);
|
||||
fprintf(stderr, " Admin: %.16s...\n", cfg->admin.pubkey);
|
||||
fprintf(stderr, " LLM: %s @ %s\n", cfg->llm.model, cfg->llm.base_url);
|
||||
fprintf(stderr, " Relays: %d configured\n\n", cfg->relay_count);
|
||||
|
||||
print_option('b', "oot the agent now");
|
||||
print_option('i', "nstall dedicated-user systemd service and boot");
|
||||
print_option('q', "uit");
|
||||
|
||||
wizard_option_t launch_opts[] = {{'b', ""}, {'i', ""}, {'q', ""}};
|
||||
char lc = read_menu_choice(launch_opts, 3);
|
||||
if (lc == 'q') return -1;
|
||||
|
||||
if (lc == 'b') {
|
||||
if (relay_changed) {
|
||||
if (ensure_startup_kind10002_from_current_relays(cfg) != 0) {
|
||||
fprintf(stderr,
|
||||
"%sFailed to stage updated relay list for startup publish.%s\n",
|
||||
ANSI_RED,
|
||||
ANSI_RESET);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
/* Always return BOOTSTRAP so main() re-publishes kind 30078.
|
||||
The wizard has the authoritative config in memory. */
|
||||
return SETUP_WIZARD_RC_BOOTSTRAP;
|
||||
}
|
||||
|
||||
if (lc == 'i') {
|
||||
/* Always publish kind 30078 before installing the service, even if
|
||||
config was not changed. The service depends on recovering these
|
||||
from relays and the event may have been purged. */
|
||||
if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) {
|
||||
fprintf(stderr,
|
||||
"%sFailed to publish runtime config to Nostr; refusing install to avoid stale admin/LLM on first boot.%s\n",
|
||||
ANSI_RED,
|
||||
ANSI_RESET);
|
||||
return -1;
|
||||
}
|
||||
if (relay_changed) {
|
||||
if (publish_kind10002_relays_wizard_online(cfg) != 0) {
|
||||
fprintf(stderr,
|
||||
"%sFailed to publish updated relay list (kind 10002) to Nostr; refusing install.%s\n",
|
||||
ANSI_RED,
|
||||
ANSI_RESET);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) {
|
||||
fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET);
|
||||
return -1;
|
||||
}
|
||||
|
||||
char service_user[64] = {0};
|
||||
sanitize_service_user_from_name(agent_name, service_user, sizeof(service_user));
|
||||
|
||||
char service_name[96] = {0};
|
||||
snprintf(service_name, sizeof(service_name), "%s.service", service_user);
|
||||
|
||||
sleep(2);
|
||||
char* is_active_argv[] = {"systemctl", "is-active", "--quiet", service_name, NULL};
|
||||
if (run_privileged_command_local(is_active_argv) == 0) {
|
||||
fprintf(stderr,
|
||||
"%sService %s is active. Setup is complete; this wizard will now exit.%s\n",
|
||||
ANSI_YELLOW,
|
||||
service_name,
|
||||
ANSI_RESET);
|
||||
} else {
|
||||
fprintf(stderr,
|
||||
"%sService installed but not active yet. Check status with: sudo systemctl status %s%s\n",
|
||||
ANSI_RED,
|
||||
service_name,
|
||||
ANSI_RESET);
|
||||
}
|
||||
|
||||
return SETUP_WIZARD_RC_EXIT;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
static int load_genesis_path_flow(didactyl_config_t* cfg) {
|
||||
@@ -1876,8 +2502,10 @@ int setup_wizard_run(didactyl_config_t* config, char* genesis_path_out, size_t g
|
||||
|
||||
if (c == 'e') {
|
||||
int rc = existing_agent_flow(config);
|
||||
if (rc == 0) {
|
||||
return SETUP_WIZARD_RC_EXISTING;
|
||||
if (rc == SETUP_WIZARD_RC_BOOTSTRAP ||
|
||||
rc == SETUP_WIZARD_RC_EXISTING ||
|
||||
rc == SETUP_WIZARD_RC_EXIT) {
|
||||
return rc;
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
@@ -30,113 +30,72 @@ static cJSON* parse_args_local(const char* args_json) {
|
||||
return args;
|
||||
}
|
||||
|
||||
static int json_object_set_string(cJSON* obj, const char* key, const char* value) {
|
||||
if (!obj || !key || !value) return -1;
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(obj, key);
|
||||
cJSON* v = cJSON_CreateString(value);
|
||||
if (!v) return -1;
|
||||
cJSON_AddItemToObject(obj, key, v);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static char* read_entire_file_local(const char* file_path, size_t* out_bytes) {
|
||||
if (!file_path) return NULL;
|
||||
|
||||
FILE* fp = fopen(file_path, "rb");
|
||||
if (!fp) return NULL;
|
||||
|
||||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cfg, char** out_error) {
|
||||
if (!ctx || !ctx->cfg || !cfg) {
|
||||
if (out_error) *out_error = strdup("invalid persist context");
|
||||
return -1;
|
||||
}
|
||||
|
||||
long len = ftell(fp);
|
||||
if (len < 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
if (out_error) *out_error = NULL;
|
||||
|
||||
cJSON* args = cJSON_CreateObject();
|
||||
cJSON* content = cJSON_CreateObject();
|
||||
if (!args || !content) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(content);
|
||||
if (out_error) *out_error = strdup("allocation failure while building llm_config payload");
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_SET) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
cJSON_AddStringToObject(args, "d_tag", "llm_config");
|
||||
cJSON_AddStringToObject(content, "provider", cfg->provider);
|
||||
cJSON_AddStringToObject(content, "api_key", cfg->api_key);
|
||||
cJSON_AddStringToObject(content, "model", cfg->model);
|
||||
cJSON_AddStringToObject(content, "base_url", cfg->base_url);
|
||||
cJSON_AddNumberToObject(content, "max_tokens", cfg->max_tokens);
|
||||
cJSON_AddNumberToObject(content, "temperature", cfg->temperature);
|
||||
cJSON_AddItemToObject(args, "content", content);
|
||||
|
||||
char* store_args_json = cJSON_PrintUnformatted(args);
|
||||
cJSON_Delete(args);
|
||||
if (!store_args_json) {
|
||||
if (out_error) *out_error = strdup("failed to serialize llm_config payload");
|
||||
return -1;
|
||||
}
|
||||
|
||||
char* buf = (char*)malloc((size_t)len + 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
char* store_result = execute_config_store(ctx, store_args_json);
|
||||
free(store_args_json);
|
||||
if (!store_result) {
|
||||
if (out_error) *out_error = strdup("config_store returned no response");
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t n = fread(buf, 1, (size_t)len, fp);
|
||||
fclose(fp);
|
||||
if (n != (size_t)len) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
buf[len] = '\0';
|
||||
if (out_bytes) *out_bytes = (size_t)len;
|
||||
return buf;
|
||||
}
|
||||
|
||||
static int persist_llm_config(tools_context_t* ctx, const llm_config_t* cfg) {
|
||||
if (!ctx || !ctx->cfg || !cfg) return -1;
|
||||
if (ctx->cfg->config_path[0] == '\0') return -1;
|
||||
|
||||
size_t src_len = 0;
|
||||
char* raw = read_entire_file_local(ctx->cfg->config_path, &src_len);
|
||||
if (!raw) return -1;
|
||||
|
||||
char* src = jsonc_strip_comments(raw, src_len);
|
||||
free(raw);
|
||||
if (!src) return -1;
|
||||
|
||||
cJSON* root = cJSON_ParseWithLength(src, strlen(src));
|
||||
free(src);
|
||||
cJSON* root = cJSON_Parse(store_result);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
if (out_error) *out_error = strdup("config_store returned invalid JSON");
|
||||
free(store_result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm");
|
||||
if (!llm || !cJSON_IsObject(llm)) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "llm");
|
||||
llm = cJSON_CreateObject();
|
||||
if (!llm) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
cJSON* success = cJSON_GetObjectItemCaseSensitive(root, "success");
|
||||
if (!success || !cJSON_IsBool(success) || !cJSON_IsTrue(success)) {
|
||||
cJSON* err = cJSON_GetObjectItemCaseSensitive(root, "error");
|
||||
if (out_error) {
|
||||
if (err && cJSON_IsString(err) && err->valuestring) {
|
||||
*out_error = strdup(err->valuestring);
|
||||
} else {
|
||||
*out_error = strdup("config_store failed");
|
||||
}
|
||||
}
|
||||
cJSON_AddItemToObject(root, "llm", llm);
|
||||
}
|
||||
|
||||
if (json_object_set_string(llm, "provider", cfg->provider) != 0 ||
|
||||
json_object_set_string(llm, "api_key", cfg->api_key) != 0 ||
|
||||
json_object_set_string(llm, "model", cfg->model) != 0 ||
|
||||
json_object_set_string(llm, "base_url", cfg->base_url) != 0) {
|
||||
cJSON_Delete(root);
|
||||
free(store_result);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(llm, "max_tokens");
|
||||
cJSON_AddNumberToObject(llm, "max_tokens", cfg->max_tokens);
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(llm, "temperature");
|
||||
cJSON_AddNumberToObject(llm, "temperature", cfg->temperature);
|
||||
|
||||
char* out = cJSON_Print(root);
|
||||
cJSON_Delete(root);
|
||||
if (!out) return -1;
|
||||
|
||||
FILE* fp = fopen(ctx->cfg->config_path, "wb");
|
||||
if (!fp) {
|
||||
free(out);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t out_len = strlen(out);
|
||||
size_t n = fwrite(out, 1, out_len, fp);
|
||||
fclose(fp);
|
||||
free(out);
|
||||
return n == out_len ? 0 : -1;
|
||||
free(store_result);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* changed) {
|
||||
@@ -231,9 +190,9 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) {
|
||||
}
|
||||
|
||||
ctx->cfg->llm = cfg;
|
||||
if (persist_llm_config(ctx, &cfg) != 0) {
|
||||
return json_error_local("failed to persist llm config to config file");
|
||||
}
|
||||
|
||||
char* persist_error = NULL;
|
||||
int persisted = (persist_llm_config_nostr(ctx, &cfg, &persist_error) == 0) ? 1 : 0;
|
||||
|
||||
cJSON* out = cJSON_CreateObject();
|
||||
if (!out) return NULL;
|
||||
@@ -243,9 +202,14 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) {
|
||||
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
|
||||
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
|
||||
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
|
||||
cJSON_AddBoolToObject(out, "persisted", persisted ? 1 : 0);
|
||||
if (!persisted && persist_error && persist_error[0] != '\0') {
|
||||
cJSON_AddStringToObject(out, "persist_warning", persist_error);
|
||||
}
|
||||
|
||||
char* json = cJSON_PrintUnformatted(out);
|
||||
cJSON_Delete(out);
|
||||
free(persist_error);
|
||||
return json;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
#include "tools_internal.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "cjson/cJSON.h"
|
||||
#include "../nostr_handler.h"
|
||||
#include "../../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
|
||||
#define TASKS_KIND 30078
|
||||
#define TASKS_D_TAG "tasks"
|
||||
#define TASKS_APP_TAG "didactyl"
|
||||
|
||||
static char* json_error_local(const char* msg) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
@@ -31,33 +35,6 @@ static cJSON* parse_args_local(const char* args_json) {
|
||||
return args;
|
||||
}
|
||||
|
||||
static int is_safe_relative_path_local(const char* path) {
|
||||
if (!path || path[0] == '\0') return 0;
|
||||
if (path[0] == '/') return 0;
|
||||
if (strstr(path, "..") != NULL) return 0;
|
||||
if (strchr(path, '\\') != NULL) return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) {
|
||||
if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1;
|
||||
if (!is_safe_relative_path_local(rel_path)) return -1;
|
||||
|
||||
const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0'
|
||||
? ctx->cfg->tools.shell.working_directory
|
||||
: ".";
|
||||
|
||||
int n = 0;
|
||||
if (strcmp(cwd, ".") == 0) {
|
||||
n = snprintf(out, out_size, "%s", rel_path);
|
||||
} else {
|
||||
n = snprintf(out, out_size, "%s/%s", cwd, rel_path);
|
||||
}
|
||||
|
||||
if (n < 0 || (size_t)n >= out_size) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static cJSON* tasks_create_empty_root_local(void) {
|
||||
cJSON* root = cJSON_CreateObject();
|
||||
cJSON* tasks = cJSON_CreateArray();
|
||||
@@ -79,47 +56,118 @@ static const char* normalize_task_status_local(const char* status) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static cJSON* tasks_load_root_local(const char* path) {
|
||||
if (!path) return NULL;
|
||||
static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, char** out_ciphertext) {
|
||||
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
|
||||
|
||||
FILE* fp = fopen(path, "rb");
|
||||
if (!fp) {
|
||||
if (access(path, F_OK) == 0) {
|
||||
return NULL;
|
||||
}
|
||||
const char* plain = plaintext ? plaintext : "";
|
||||
size_t out_cap = (strlen(plain) * 4U) + 1024U;
|
||||
char* ciphertext = (char*)malloc(out_cap);
|
||||
if (!ciphertext) return -1;
|
||||
|
||||
int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key,
|
||||
ctx->cfg->keys.public_key,
|
||||
plain,
|
||||
ciphertext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(ciphertext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_ciphertext = ciphertext;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) {
|
||||
if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1;
|
||||
|
||||
size_t out_cap = strlen(ciphertext) + 1024U;
|
||||
char* plaintext = (char*)malloc(out_cap);
|
||||
if (!plaintext) return -1;
|
||||
|
||||
int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key,
|
||||
ctx->cfg->keys.public_key,
|
||||
ciphertext,
|
||||
plaintext,
|
||||
out_cap);
|
||||
if (rc != NOSTR_SUCCESS) {
|
||||
free(plaintext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
*out_plaintext = plaintext;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int query_tasks_ciphertext_local(tools_context_t* ctx, char** out_ciphertext) {
|
||||
if (!ctx || !ctx->cfg || !out_ciphertext) return -1;
|
||||
|
||||
cJSON* filter = cJSON_CreateObject();
|
||||
cJSON* kinds = cJSON_CreateArray();
|
||||
cJSON* authors = cJSON_CreateArray();
|
||||
cJSON* d_vals = cJSON_CreateArray();
|
||||
if (!filter || !kinds || !authors || !d_vals) {
|
||||
cJSON_Delete(filter);
|
||||
cJSON_Delete(kinds);
|
||||
cJSON_Delete(authors);
|
||||
cJSON_Delete(d_vals);
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(TASKS_KIND));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
|
||||
cJSON_AddItemToArray(d_vals, cJSON_CreateString(TASKS_D_TAG));
|
||||
cJSON_AddItemToObject(filter, "#d", d_vals);
|
||||
|
||||
cJSON_AddNumberToObject(filter, "limit", 1);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 4000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* arr = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) {
|
||||
cJSON_Delete(arr);
|
||||
*out_ciphertext = strdup("");
|
||||
return (*out_ciphertext != NULL) ? 0 : -1;
|
||||
}
|
||||
|
||||
cJSON* ev = cJSON_GetArrayItem(arr, 0);
|
||||
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
|
||||
const char* cipher = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : "";
|
||||
|
||||
*out_ciphertext = strdup(cipher);
|
||||
cJSON_Delete(arr);
|
||||
return (*out_ciphertext != NULL) ? 0 : -1;
|
||||
}
|
||||
|
||||
static cJSON* tasks_load_root_nostr_local(tools_context_t* ctx) {
|
||||
char* ciphertext = NULL;
|
||||
if (query_tasks_ciphertext_local(ctx, &ciphertext) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (!ciphertext || ciphertext[0] == '\0') {
|
||||
free(ciphertext);
|
||||
return tasks_create_empty_root_local();
|
||||
}
|
||||
|
||||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
long len = ftell(fp);
|
||||
if (len < 0) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
if (fseek(fp, 0, SEEK_SET) != 0) {
|
||||
fclose(fp);
|
||||
char* plaintext = NULL;
|
||||
if (nip44_decrypt_self_local(ctx, ciphertext, &plaintext) != 0) {
|
||||
free(ciphertext);
|
||||
return NULL;
|
||||
}
|
||||
free(ciphertext);
|
||||
|
||||
char* buf = (char*)malloc((size_t)len + 1U);
|
||||
if (!buf) {
|
||||
fclose(fp);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t n = fread(buf, 1, (size_t)len, fp);
|
||||
fclose(fp);
|
||||
if (n != (size_t)len) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
buf[len] = '\0';
|
||||
|
||||
cJSON* root = cJSON_Parse(buf);
|
||||
free(buf);
|
||||
cJSON* root = cJSON_Parse(plaintext);
|
||||
free(plaintext);
|
||||
if (!root || !cJSON_IsObject(root)) {
|
||||
cJSON_Delete(root);
|
||||
return NULL;
|
||||
@@ -140,22 +188,45 @@ static cJSON* tasks_load_root_local(const char* path) {
|
||||
return root;
|
||||
}
|
||||
|
||||
static int tasks_save_root_local(const char* path, cJSON* root) {
|
||||
if (!path || !root) return -1;
|
||||
char* raw = cJSON_PrintUnformatted(root);
|
||||
if (!raw) return -1;
|
||||
static int tasks_save_root_nostr_local(tools_context_t* ctx, cJSON* root) {
|
||||
if (!ctx || !ctx->cfg || !root) return -1;
|
||||
|
||||
FILE* fp = fopen(path, "wb");
|
||||
if (!fp) {
|
||||
free(raw);
|
||||
char* plaintext = cJSON_PrintUnformatted(root);
|
||||
if (!plaintext) return -1;
|
||||
|
||||
char* ciphertext = NULL;
|
||||
if (nip44_encrypt_self_local(ctx, plaintext, &ciphertext) != 0) {
|
||||
free(plaintext);
|
||||
return -1;
|
||||
}
|
||||
free(plaintext);
|
||||
|
||||
cJSON* tags = cJSON_CreateArray();
|
||||
cJSON* d_tag = cJSON_CreateArray();
|
||||
cJSON* app_tag = cJSON_CreateArray();
|
||||
if (!tags || !d_tag || !app_tag) {
|
||||
cJSON_Delete(tags);
|
||||
cJSON_Delete(d_tag);
|
||||
cJSON_Delete(app_tag);
|
||||
free(ciphertext);
|
||||
return -1;
|
||||
}
|
||||
|
||||
size_t len = strlen(raw);
|
||||
size_t n = fwrite(raw, 1, len, fp);
|
||||
fclose(fp);
|
||||
free(raw);
|
||||
return (n == len) ? 0 : -1;
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
|
||||
cJSON_AddItemToArray(d_tag, cJSON_CreateString(TASKS_D_TAG));
|
||||
cJSON_AddItemToArray(tags, d_tag);
|
||||
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString("app"));
|
||||
cJSON_AddItemToArray(app_tag, cJSON_CreateString(TASKS_APP_TAG));
|
||||
cJSON_AddItemToArray(tags, app_tag);
|
||||
|
||||
nostr_publish_result_t publish_result;
|
||||
memset(&publish_result, 0, sizeof(publish_result));
|
||||
int rc = nostr_handler_publish_kind_event(TASKS_KIND, ciphertext, tags, &publish_result);
|
||||
cJSON_Delete(tags);
|
||||
free(ciphertext);
|
||||
nostr_handler_publish_result_free(&publish_result);
|
||||
return rc == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
static cJSON* task_find_by_id_local(cJSON* tasks, int id, int* out_index) {
|
||||
@@ -184,16 +255,10 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
|
||||
return json_error_local("task_manage requires string action");
|
||||
}
|
||||
|
||||
char tasks_path[PATH_MAX];
|
||||
if (build_tool_path_local(ctx, "tasks.json", tasks_path, sizeof(tasks_path)) != 0) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to resolve tasks file path");
|
||||
}
|
||||
|
||||
cJSON* root = tasks_load_root_local(tasks_path);
|
||||
cJSON* root = tasks_load_root_nostr_local(ctx);
|
||||
if (!root) {
|
||||
cJSON_Delete(args);
|
||||
return json_error_local("failed to load tasks file");
|
||||
return json_error_local("failed to load tasks memory");
|
||||
}
|
||||
|
||||
cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks");
|
||||
@@ -405,10 +470,10 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
|
||||
if (mutated) {
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id");
|
||||
cJSON_AddNumberToObject(root, "next_id", next_id);
|
||||
if (tasks_save_root_local(tasks_path, root) != 0) {
|
||||
if (tasks_save_root_nostr_local(ctx, root) != 0) {
|
||||
cJSON_Delete(args);
|
||||
cJSON_Delete(root);
|
||||
return json_error_local("failed to save tasks file");
|
||||
return json_error_local("failed to persist tasks memory");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +486,6 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) {
|
||||
|
||||
cJSON_AddBoolToObject(out, "success", 1);
|
||||
cJSON_AddStringToObject(out, "action", action_s);
|
||||
cJSON_AddStringToObject(out, "path", tasks_path);
|
||||
cJSON_AddBoolToObject(out, "mutated", mutated ? 1 : 0);
|
||||
cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(tasks));
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#ifndef DIDACTYL_TOOLS_H
|
||||
#define DIDACTYL_TOOLS_H
|
||||
|
||||
#include "config.h"
|
||||
#include "../config.h"
|
||||
|
||||
struct trigger_manager;
|
||||
|
||||
|
||||
@@ -687,6 +687,9 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON* p_skill_create_llm = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_create_llm, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_create_llm,
|
||||
"description",
|
||||
"Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only.");
|
||||
cJSON_AddItemToObject(t22_props, "llm", p_skill_create_llm);
|
||||
|
||||
cJSON* p_skill_create_tools = cJSON_CreateObject();
|
||||
@@ -840,6 +843,9 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON* p_skill_edit_llm = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(p_skill_edit_llm, "type", "string");
|
||||
cJSON_AddStringToObject(p_skill_edit_llm,
|
||||
"description",
|
||||
"Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only.");
|
||||
cJSON_AddItemToObject(t25b_props, "llm", p_skill_edit_llm);
|
||||
|
||||
cJSON* p_skill_edit_tools = cJSON_CreateObject();
|
||||
@@ -976,7 +982,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON_AddStringToObject(t31, "type", "function");
|
||||
cJSON_AddStringToObject(t31_fn, "name", "model_set");
|
||||
cJSON_AddStringToObject(t31_fn, "description", "Update active LLM configuration and persist it to config.jsonc");
|
||||
cJSON_AddStringToObject(t31_fn, "description", "Update active LLM configuration and persist it to Nostr kind 30078 (d=llm_config)");
|
||||
cJSON_AddStringToObject(t31_params, "type", "object");
|
||||
cJSON_AddItemToObject(t31_params, "properties", t31_props);
|
||||
|
||||
@@ -1276,7 +1282,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON_AddStringToObject(t42, "type", "function");
|
||||
cJSON_AddStringToObject(t42_fn, "name", "task_list");
|
||||
cJSON_AddStringToObject(t42_fn, "description", "Build current task list context block from tasks.json");
|
||||
cJSON_AddStringToObject(t42_fn, "description", "Build current task list context block from agent task memory on Nostr");
|
||||
cJSON_AddStringToObject(t42_params, "type", "object");
|
||||
cJSON_AddItemToObject(t42_params, "properties", t42_props);
|
||||
cJSON_AddItemToObject(t42_fn, "parameters", t42_params);
|
||||
@@ -1305,7 +1311,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) {
|
||||
|
||||
cJSON_AddStringToObject(t45, "type", "function");
|
||||
cJSON_AddStringToObject(t45_fn, "name", "task_manage");
|
||||
cJSON_AddStringToObject(t45_fn, "description", "Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)");
|
||||
cJSON_AddStringToObject(t45_fn, "description", "Manage agent short-term task memory stored on Nostr kind 30078 (d=tasks): list/add/update/remove/clear/replace");
|
||||
cJSON_AddStringToObject(t45_params, "type", "object");
|
||||
cJSON_AddItemToObject(t45_params, "properties", t45_props);
|
||||
cJSON_AddItemToObject(t45_params, "required", t45_required);
|
||||
|
||||
Reference in New Issue
Block a user