Compare commits

...
13 Commits
Author SHA1 Message Date
Your Name b1a767f9b5 v0.0.86 - Return masked api_key field in model_get/model_set responses 2026-03-18 17:42:16 -04:00
Your Name 650df7bc41 v0.0.85 - Skip llm_config republish on startup when config was recalled from Nostr 2026-03-18 17:34:11 -04:00
Your Name 9d06f18769 v0.0.84 - Remove duplicated read-only tool payload fields and make slash output prefer content 2026-03-18 16:48:04 -04:00
Your Name 527016c078 v0.0.83 - Add my_kind0_profile cache-miss fallback to query kind-0 from relays and cache result 2026-03-18 16:32:14 -04:00
Your Name c221104bc0 v0.0.82 - Move model/task persistence to kind 30078, add finish_reason stall diagnostics, and remove local config/tasks file coupling 2026-03-18 15:58:33 -04:00
Your Name 253aa4b5fc v0.0.81 - Add loop progress controls and admin limit diagnostics for DM, HTTP API, and triggers 2026-03-18 14:16:10 -04:00
Your Name 82362e4f3f v0.0.80 - Fix wizard/runtime config persistence and add --admin CLI override for agent_config updates 2026-03-18 11:21:57 -04:00
Your Name 7675109afc v0.0.79 - Enhance existing-agent wizard with full config recovery review edit flow and new-agent fallback 2026-03-18 10:14:10 -04:00
Your Name 42fc14a5b3 v0.0.78 - Seed default skill into self-skill cache during startup reconcile 2026-03-18 09:46:08 -04:00
Your Name 326acb63d1 v0.0.77 - Wizard now verifies systemd service is active after install, reports admin inbox expectation, and exits to avoid duplicate foreground boot 2026-03-18 07:44:16 -04:00
Your Name 6b6e25c3f4 v0.0.76 - Use sudo-prefixed privileged commands in setup wizard install flow with password prompting and remove root-shell handoff 2026-03-18 07:07:30 -04:00
Your Name 9a0e90dff0 v0.0.75 - Add rsync deploy script and wizard root-shell handoff for service install path 2026-03-18 06:54:30 -04:00
Your Name 0ce9de002b v0.0.74 - Add default skill d-tag so startup-published kind 31124 events seed self-skill cache and appear in available skills 2026-03-18 06:31:07 -04:00
28 changed files with 2129 additions and 393 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ test_keys.txt
/mongoose/
/Trash/
deploy_lt.sh
# Build artifacts
/build/
+2 -2
View File
@@ -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.73
## Current Status — v0.0.86
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.73 — Refined Nostr handler and tool dispatch/schema updates for relay and my-events flows
> Last release update: v0.0.86 — Return masked api_key field in model_get/model_set responses
- Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected
+5 -4
View File
@@ -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": {
Executable
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_BINARY="$SCRIPT_DIR/didactyl_static_x86_64"
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 ! command -v ssh >/dev/null 2>&1; then
echo "ERROR: ssh is not installed or not in PATH"
exit 1
fi
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
View 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
View 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.
@@ -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
@@ -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
+164 -7
View File
@@ -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);
@@ -371,6 +437,15 @@ static char* format_result_markdown_local(const char* raw_result) {
return strdup(raw_result);
}
if (cJSON_IsObject(root)) {
cJSON* content = cJSON_GetObjectItemCaseSensitive(root, "content");
if (content && cJSON_IsString(content) && content->valuestring) {
char* direct = strdup(content->valuestring);
cJSON_Delete(root);
return direct ? direct : strdup(raw_result);
}
}
size_t cap = strlen(raw_result) * 2U + 512U;
if (cap < 2048U) cap = 2048U;
size_t used = 0U;
@@ -508,10 +583,15 @@ static char* build_slash_help_all_json(void) {
char* adopted_json = tools_execute(&g_tools_ctx, "adopted_skills", "{}");
cJSON* adopted_root = adopted_json ? cJSON_Parse(adopted_json) : NULL;
cJSON* adoption_events_content = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "content") : NULL;
cJSON* adoption_events_json = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "adoption_events_json") : NULL;
cJSON* adoption_events = (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring)
? cJSON_Parse(adoption_events_json->valuestring)
: NULL;
const char* adoption_events_text = NULL;
if (adoption_events_content && cJSON_IsString(adoption_events_content) && adoption_events_content->valuestring) {
adoption_events_text = adoption_events_content->valuestring;
} else if (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring) {
adoption_events_text = adoption_events_json->valuestring;
}
cJSON* adoption_events = adoption_events_text ? cJSON_Parse(adoption_events_text) : NULL;
int adopted_count = 0;
if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) {
@@ -661,8 +741,11 @@ static int handle_slash_command(const char* sender_pubkey_hex, const char* messa
result_json = strdup("{\"success\":false,\"error\":\"direct tool execution failed\"}");
}
char* markdown_result = format_result_markdown_local(result_json);
const char* dm_payload = markdown_result ? markdown_result : result_json;
size_t tool_name_len = strlen(tool_name);
int send_raw_json = (tool_name_len >= 4U && strcmp(tool_name + tool_name_len - 4U, "_get") == 0);
char* markdown_result = send_raw_json ? NULL : format_result_markdown_local(result_json);
const char* dm_payload = send_raw_json ? result_json : (markdown_result ? markdown_result : result_json);
size_t log_cap = strlen(message) + strlen(result_json ? result_json : "") + strlen(dm_payload ? dm_payload : "") + 192U;
char* log_payload = (char*)malloc(log_cap);
@@ -1815,7 +1898,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 +1922,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 +1958,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 +2198,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 +2236,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 +2285,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 ? "..." : "");
+1 -1
View File
@@ -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;
+12 -4
View File
@@ -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;
+1
View File
@@ -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;
+1 -1
View File
@@ -3,7 +3,7 @@
#define DIDACTYL_DEFAULT_SKILL_D_TAG "didactyl-default"
#define DIDACTYL_DEFAULT_SKILL_KIND 31124
#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"app\",\"didactyl\"],[\"scope\",\"private\"]]"
#define DIDACTYL_DEFAULT_SKILL_TAGS_JSON "[[\"d\",\"didactyl-default\"],[\"app\",\"didactyl\"],[\"scope\",\"private\"]]"
#define DIDACTYL_STARTUP_KIND_PROFILE 0
#define DIDACTYL_STARTUP_KIND_CONTACTS 3
+147 -5
View File
@@ -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,
@@ -649,10 +726,15 @@ static char* build_slash_help_all_json_local(void) {
char* adopted_json = tools_execute(g_api_ctx.tools_ctx, "adopted_skills", "{}");
cJSON* adopted_root = adopted_json ? cJSON_Parse(adopted_json) : NULL;
cJSON* adoption_events_content = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "content") : NULL;
cJSON* adoption_events_json = adopted_root ? cJSON_GetObjectItemCaseSensitive(adopted_root, "adoption_events_json") : NULL;
cJSON* adoption_events = (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring)
? cJSON_Parse(adoption_events_json->valuestring)
: NULL;
const char* adoption_events_text = NULL;
if (adoption_events_content && cJSON_IsString(adoption_events_content) && adoption_events_content->valuestring) {
adoption_events_text = adoption_events_content->valuestring;
} else if (adoption_events_json && cJSON_IsString(adoption_events_json) && adoption_events_json->valuestring) {
adoption_events_text = adoption_events_json->valuestring;
}
cJSON* adoption_events = adoption_events_text ? cJSON_Parse(adoption_events_text) : NULL;
int adopted_count = 0;
if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) {
@@ -844,9 +926,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 +970,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 +1025,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);
+1 -1
View File
@@ -2,7 +2,7 @@
#define DIDACTYL_HTTP_API_H
#include "config.h"
#include "tools.h"
#include "tools/tools.h"
struct trigger_manager;
+6
View File
@@ -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);
+1
View File
@@ -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);
+71 -34
View File
@@ -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"
@@ -316,10 +318,11 @@ static int query_self_kind10002_relays(const didactyl_config_t* cfg,
return 0;
}
size_t events_json_len = strlen(events_json);
cJSON* events = cJSON_Parse(events_json);
free(events_json);
if (!events || !cJSON_IsArray(events)) {
DEBUG_WARN("[didactyl] kind10002 query parse failed or non-array payload (len=%zu)", strlen(events_json));
DEBUG_WARN("[didactyl] kind10002 query parse failed or non-array payload (len=%zu)", events_json_len);
cJSON_Delete(events);
return 0;
}
@@ -810,24 +813,29 @@ static int publish_encrypted_self_config(const didactyl_config_t* cfg, const cha
return rc;
}
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg) {
static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg, int persist_llm_config) {
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);
int llm_rc = 0;
if (persist_llm_config) {
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;
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
int llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
free(llm_json);
llm_rc = publish_encrypted_self_config(cfg, "llm_config", llm_json);
free(llm_json);
} else {
DEBUG_INFO("[didactyl] startup phase: skipping llm_config publish because it was recalled from Nostr");
}
cJSON* agent = cJSON_CreateObject();
if (!agent) return llm_rc;
@@ -843,15 +851,18 @@ static int persist_runtime_config_to_nostr(const didactyl_config_t* cfg) {
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1;
}
static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
static int recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
if (!cfg) {
return;
return 0;
}
int llm_recalled_from_nostr = 0;
if (!llm_config_is_complete(&cfg->llm)) {
char* llm_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
if (apply_recalled_llm_config(cfg, llm_plaintext) == 0) {
llm_recalled_from_nostr = 1;
DEBUG_INFO("[didactyl] startup phase: recovered llm_config from Nostr");
} else {
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled llm_config");
@@ -862,24 +873,34 @@ 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);
return llm_recalled_from_nostr;
}
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;
@@ -943,6 +964,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) {
@@ -996,6 +1019,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);
@@ -1104,8 +1139,8 @@ int main(int argc, char** argv) {
}
startup_step_begin(2, "Recover runtime config from Nostr");
recover_missing_runtime_config_from_nostr(&cfg);
startup_step_ok(2, "Recover runtime config from Nostr", NULL);
int llm_recalled_from_nostr = recover_missing_runtime_config_from_nostr(&cfg);
startup_step_ok(2, "Recover runtime config from Nostr", llm_recalled_from_nostr ? "llm_config recalled" : NULL);
startup_step_begin(3, "Validate LLM config");
if (!llm_config_is_complete(&cfg.llm)) {
@@ -1123,7 +1158,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();
@@ -1152,17 +1187,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, llm_recalled_from_nostr ? 0 : 1) != 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();
+2 -2
View File
@@ -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 73
#define DIDACTYL_VERSION "v0.0.73"
#define DIDACTYL_VERSION_PATCH 86
#define DIDACTYL_VERSION "v0.0.86"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+136 -1
View File
@@ -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;
}
@@ -3755,6 +3802,55 @@ char* nostr_handler_get_admin_kind1_notes_context(void) {
return out;
}
static char* extract_latest_kind0_content_from_events_json(const char* events_json) {
if (!events_json || events_json[0] == '\0') {
return NULL;
}
cJSON* root = cJSON_Parse(events_json);
if (!root || !cJSON_IsArray(root)) {
cJSON_Delete(root);
return NULL;
}
char* best_content = NULL;
time_t best_created_at = 0;
int n = cJSON_GetArraySize(root);
for (int i = 0; i < n; i++) {
cJSON* ev = cJSON_GetArrayItem(root, i);
if (!ev || !cJSON_IsObject(ev)) {
continue;
}
cJSON* kind = cJSON_GetObjectItemCaseSensitive(ev, "kind");
cJSON* content = cJSON_GetObjectItemCaseSensitive(ev, "content");
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
if (!kind || !cJSON_IsNumber(kind) || (int)kind->valuedouble != 0 ||
!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
continue;
}
time_t ev_created_at = 0;
if (created_at && cJSON_IsNumber(created_at)) {
ev_created_at = (time_t)created_at->valuedouble;
}
if (!best_content || ev_created_at >= best_created_at) {
char* dup = strdup(content->valuestring);
if (!dup) {
continue;
}
free(best_content);
best_content = dup;
best_created_at = ev_created_at;
}
}
cJSON_Delete(root);
return best_content;
}
char* nostr_handler_get_agent_kind0_context(void) {
if (!g_cfg) {
return NULL;
@@ -3763,7 +3859,46 @@ char* nostr_handler_get_agent_kind0_context(void) {
pthread_mutex_lock(&g_admin_ctx_mutex);
char* out = g_agent_kind0_json ? strdup(g_agent_kind0_json) : NULL;
pthread_mutex_unlock(&g_admin_ctx_mutex);
return out;
if (out && out[0] != '\0') {
return out;
}
free(out);
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 NULL;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(g_cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 8);
char* events_json = nostr_handler_query_json(filter, 3000);
cJSON_Delete(filter);
if (!events_json) {
return NULL;
}
char* fetched_kind0 = extract_latest_kind0_content_from_events_json(events_json);
free(events_json);
if (!fetched_kind0 || fetched_kind0[0] == '\0') {
free(fetched_kind0);
return NULL;
}
pthread_mutex_lock(&g_admin_ctx_mutex);
free(g_agent_kind0_json);
g_agent_kind0_json = strdup(fetched_kind0);
pthread_mutex_unlock(&g_admin_ctx_mutex);
return fetched_kind0;
}
char* nostr_handler_get_agent_kind3_context(void) {
+1 -1
View File
@@ -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
+828 -134
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -47,8 +47,6 @@ char* execute_admin_identity(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_pubkey", ctx->cfg->admin.pubkey[0] ? ctx->cfg->admin.pubkey : "unknown");
cJSON_AddStringToObject(out, "sender_verification", tier_text);
char content[768];
snprintf(content,
@@ -82,7 +80,6 @@ char* execute_nostr_admin_profile(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind0_json", profile_json);
size_t content_len = strlen("## Administrator Kind 0 Profile (source: nostr kind 0)\n\nAdministrator kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
char* content = (char*)malloc(content_len);
@@ -121,7 +118,6 @@ char* execute_nostr_admin_contacts(tools_context_t* ctx, const char* args_json)
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind3_json", contacts_json);
size_t content_len = strlen("## Administrator Kind 3 Contacts (source: nostr kind 3)\n\nAdministrator contacts (JSON): ") + strlen(contacts_json) + 1U;
char* content = (char*)malloc(content_len);
@@ -160,7 +156,6 @@ char* execute_nostr_admin_relays(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_kind10002_json", relays_json);
size_t content_len = strlen("## Administrator Kind 10002 Relays (source: nostr kind 10002)\n\nAdministrator relay list (JSON): ") + strlen(relays_json) + 1U;
char* content = (char*)malloc(content_len);
@@ -199,7 +194,6 @@ char* execute_nostr_admin_notes(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "admin_notes_content", notes_text);
size_t content_len = strlen("## Administrator Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
char* content = (char*)malloc(content_len);
-8
View File
@@ -62,8 +62,6 @@ char* execute_agent_identity(tools_context_t* ctx, const char* args_json) {
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_pubkey", ctx->cfg->keys.public_key_hex[0] ? ctx->cfg->keys.public_key_hex : "unknown");
cJSON_AddStringToObject(out, "agent_npub", npub);
char content[384];
snprintf(content,
@@ -95,7 +93,6 @@ char* execute_nostr_agent_profile(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_kind0_json", profile_json);
size_t content_len = strlen("## Agent Kind 0 Profile (source: nostr kind 0)\n\nAgent kind 0 profile content (JSON): ") + strlen(profile_json) + 1U;
char* content = (char*)malloc(content_len);
@@ -134,7 +131,6 @@ char* execute_nostr_agent_contacts(tools_context_t* ctx, const char* args_json)
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_kind3_json", contacts_json);
size_t content_len = strlen("## Agent Kind 3 Contacts (source: nostr kind 3)\n\nAgent contacts (JSON): ") + strlen(contacts_json) + 1U;
char* content = (char*)malloc(content_len);
@@ -173,7 +169,6 @@ char* execute_nostr_agent_relays(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_kind10002_json", relays_json);
size_t content_len = strlen("## Agent Kind 10002 Relays (source: nostr kind 10002)\n\nAgent relay list (JSON): ") + strlen(relays_json) + 1U;
char* content = (char*)malloc(content_len);
@@ -212,7 +207,6 @@ char* execute_nostr_agent_notes(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "agent_notes_content", notes_text);
size_t content_len = strlen("## Agent Recent Kind 1 Notes\n\n") + strlen(notes_text) + 1U;
char* content = (char*)malloc(content_len);
@@ -267,7 +261,6 @@ char* execute_adopted_skills(tools_context_t* ctx, const char* args_json) {
}
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", (events_json && events_json[0]) ? events_json : "[]");
cJSON_AddStringToObject(out, "adoption_events_json", (events_json && events_json[0]) ? events_json : "[]");
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
@@ -302,7 +295,6 @@ char* execute_trigger_event(tools_context_t* ctx, const char* args_json) {
if (!out) return NULL;
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "content", trigger_json);
cJSON_AddStringToObject(out, "trigger_event_json", trigger_json);
char* json = cJSON_PrintUnformatted(out);
cJSON_Delete(out);
return json;
+83 -91
View File
@@ -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) {
@@ -149,6 +108,25 @@ static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* cha
return 0;
}
static void mask_api_key_local(const char* api_key, char* out, size_t out_size) {
if (!out || out_size == 0) return;
out[0] = '\0';
if (!api_key || api_key[0] == '\0') {
return;
}
size_t api_len = strlen(api_key);
if (api_len >= 8U) {
snprintf(out,
out_size,
"%.4s...%s",
api_key,
api_key + api_len - 4U);
} else {
snprintf(out, out_size, "%s", "(set)");
}
}
char* execute_model_get(const char* args_json) {
cJSON* args = parse_args_local(args_json);
if (!args) return json_error_local("invalid arguments JSON");
@@ -161,10 +139,15 @@ char* execute_model_get(const char* args_json) {
cJSON* out = cJSON_CreateObject();
if (!out) return NULL;
char api_key_masked[64] = {0};
mask_api_key_local(cfg.api_key, api_key_masked, sizeof(api_key_masked));
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "provider", cfg.provider);
cJSON_AddStringToObject(out, "model", cfg.model);
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
cJSON_AddStringToObject(out, "api_key", api_key_masked);
cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens);
cJSON_AddNumberToObject(out, "temperature", cfg.temperature);
@@ -231,21 +214,30 @@ 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;
char api_key_masked[64] = {0};
mask_api_key_local(cfg.api_key, api_key_masked, sizeof(api_key_masked));
cJSON_AddBoolToObject(out, "success", 1);
cJSON_AddStringToObject(out, "provider", cfg.provider);
cJSON_AddStringToObject(out, "model", cfg.model);
cJSON_AddStringToObject(out, "base_url", cfg.base_url);
cJSON_AddStringToObject(out, "api_key", api_key_masked);
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;
}
+150 -86
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
#ifndef DIDACTYL_TOOLS_H
#define DIDACTYL_TOOLS_H
#include "config.h"
#include "../config.h"
struct trigger_manager;
+9 -3
View File
@@ -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);