Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f3d692784 | |||
| d71fc4c1c0 | |||
| a118d19393 | |||
| c6aa1e5c53 | |||
| ff577391f2 | |||
| ee0d5f9b97 | |||
| 391bd818ea | |||
| 43636aed99 | |||
| 8f8ccc945e | |||
| 409ec99397 | |||
| dc39a61e2c | |||
| eba9eac8a8 | |||
| ab6b52a0b4 | |||
| 2baa0b9a41 | |||
| 6a8b2461cd | |||
| bcb356fe69 | |||
| dd7827059a | |||
| 208e36c899 | |||
| b97ce74d1f | |||
| 155a449c4e | |||
| 4696fd6833 | |||
| 581a351fb1 | |||
| 8ce8656de2 | |||
| 1e49560f1f | |||
| e8f0b5a9de | |||
| 40287c4cfc | |||
| 0481bea44d | |||
| 9d565ca080 | |||
| 6b9d9e6c4a | |||
| b4967af13e | |||
| e3dabe3e08 | |||
| 0a0a2bcb44 | |||
| 5c41c66a0f |
@@ -214,9 +214,18 @@ jobs:
|
||||
env:
|
||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
||||
run: |
|
||||
# Fail loudly: previously this step had no `set -e`, so an auth or
|
||||
# upload failure was swallowed (step exited 0 on the trailing echo)
|
||||
# and the SDK silently never published. Surface failures now.
|
||||
set -euo pipefail
|
||||
if [ -z "${GCP_SA_KEY:-}" ]; then
|
||||
echo "FATAL: GCP_SA_KEY secret is empty — cannot authenticate to publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
|
||||
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
|
||||
gcloud config set project neuron-785695
|
||||
echo "Publishing as active account: $(gcloud config get-value account 2>/dev/null)"
|
||||
|
||||
VERSION="${GITHUB_SHA:0:8}"
|
||||
|
||||
@@ -268,6 +277,12 @@ jobs:
|
||||
# Patches ci-base:dev in-place: pulls the existing image (which has all
|
||||
# system deps — Node, Go, gcloud, Docker CLI, etc.) and overlays the freshly
|
||||
# built El SDK on top. Keeps the full ci-base rebuild fast and incremental.
|
||||
#
|
||||
# continue-on-error: this is a CI-cache optimization, NOT the release
|
||||
# artifact. It runs Docker (pull/build/push ~600MB) on the host-mode GCE
|
||||
# runner where DinD/Docker availability is fragile. A failure here must
|
||||
# never block or redden the job — the SDK publish above is the deliverable.
|
||||
continue-on-error: true
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
||||
|
||||
@@ -212,12 +212,21 @@ jobs:
|
||||
env:
|
||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
||||
run: |
|
||||
# Fail loudly: previously this step had no `set -e`, so an auth or
|
||||
# upload failure was swallowed (step exited 0 on the trailing echo)
|
||||
# and the SDK silently never published. Surface failures now.
|
||||
set -euo pipefail
|
||||
if [ -z "${GCP_SA_KEY:-}" ]; then
|
||||
echo "FATAL: GCP_SA_KEY secret is empty — cannot authenticate to publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
|
||||
apt-get install -y -qq apt-transport-https ca-certificates curl
|
||||
echo "deb [trusted=yes] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
|
||||
apt-get update -qq && apt-get install -y google-cloud-cli
|
||||
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
|
||||
gcloud config set project neuron-785695
|
||||
echo "Publishing as active account: $(gcloud config get-value account 2>/dev/null)"
|
||||
|
||||
VERSION="${GITHUB_SHA:0:8}"
|
||||
|
||||
@@ -253,6 +262,12 @@ jobs:
|
||||
# Patches ci-base:stage in-place: pulls the existing image (which has all
|
||||
# system deps — Node, Go, gcloud, Docker CLI, etc.) and overlays the freshly
|
||||
# built El SDK on top. Keeps the full ci-base rebuild fast and incremental.
|
||||
#
|
||||
# continue-on-error: this is a CI-cache optimization, NOT the release
|
||||
# artifact. It runs Docker (pull/build/push ~600MB) on the host-mode GCE
|
||||
# runner where DinD/Docker availability is fragile. A failure here must
|
||||
# never block or redden the job — the SDK publish above is the deliverable.
|
||||
continue-on-error: true
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
||||
|
||||
@@ -288,12 +288,21 @@ jobs:
|
||||
env:
|
||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
||||
run: |
|
||||
# Fail loudly: previously this step had no `set -e`, so an auth or
|
||||
# upload failure was swallowed (step exited 0 on the trailing echo)
|
||||
# and the SDK silently never published. Surface failures now.
|
||||
set -euo pipefail
|
||||
if [ -z "${GCP_SA_KEY:-}" ]; then
|
||||
echo "FATAL: GCP_SA_KEY secret is empty — cannot authenticate to publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
|
||||
apt-get install -y -qq apt-transport-https ca-certificates curl
|
||||
echo "deb [trusted=yes] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
|
||||
apt-get update -qq && apt-get install -y google-cloud-cli
|
||||
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
|
||||
gcloud config set project neuron-785695
|
||||
echo "Publishing as active account: $(gcloud config get-value account 2>/dev/null)"
|
||||
|
||||
VERSION="${GITHUB_SHA:0:8}"
|
||||
|
||||
@@ -345,6 +354,12 @@ jobs:
|
||||
# Patches ci-base:latest in-place: pulls the existing image (which has all
|
||||
# system deps — Node, Go, gcloud, Docker CLI, etc.) and overlays the freshly
|
||||
# built El SDK on top. Keeps the full ci-base rebuild fast and incremental.
|
||||
#
|
||||
# continue-on-error: this is a CI-cache optimization, NOT the release
|
||||
# artifact. It runs Docker (pull/build/push ~600MB) on the host-mode GCE
|
||||
# runner where DinD/Docker availability is fragile. A failure here must
|
||||
# never block or redden the job — the SDK publish above is the deliverable.
|
||||
continue-on-error: true
|
||||
if: github.event_name == 'push'
|
||||
env:
|
||||
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
# Link to produce the engram binary
|
||||
- name: Link engram binary
|
||||
run: |
|
||||
cc -std=c11 -O2 \
|
||||
cc -std=c11 -O2 -DHAVE_CURL \
|
||||
-I /usr/local/lib/el \
|
||||
-o dist/engram \
|
||||
dist/engram.c \
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
# Link to produce the engram binary
|
||||
- name: Link engram binary
|
||||
run: |
|
||||
cc -std=c11 -O2 \
|
||||
cc -std=c11 -O2 -DHAVE_CURL \
|
||||
-I /usr/local/lib/el \
|
||||
-o dist/engram \
|
||||
dist/engram.c \
|
||||
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
# Link to produce the engram binary
|
||||
- name: Link engram binary
|
||||
run: |
|
||||
cc -std=c11 -O2 \
|
||||
cc -std=c11 -O2 -DHAVE_CURL \
|
||||
-I /usr/local/lib/el \
|
||||
-o dist/engram \
|
||||
dist/engram.c \
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+142
-95
@@ -10,6 +10,7 @@ el_val_t query_param(el_val_t path, el_val_t key);
|
||||
el_val_t query_int(el_val_t path, el_val_t key, el_val_t default_val);
|
||||
el_val_t extract_id(el_val_t path, el_val_t prefix);
|
||||
el_val_t route_stats(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t persist_canonical(void);
|
||||
el_val_t route_create_node(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_get_node(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_scan_nodes(el_val_t method, el_val_t path, el_val_t body);
|
||||
@@ -20,18 +21,23 @@ el_val_t route_create_edge(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_neighbors(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_strengthen(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_forget(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_create_ise(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_sync(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_save(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_load(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_health(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_sync(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_load_merge(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_emit_ise(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t route_capture_knowledge(el_val_t method, el_val_t path, el_val_t body);
|
||||
el_val_t check_auth_ok(el_val_t method, el_val_t body);
|
||||
el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body);
|
||||
|
||||
el_val_t bind_raw;
|
||||
el_val_t bind_str;
|
||||
el_val_t port;
|
||||
el_val_t data_dir_raw;
|
||||
el_val_t data_dir;
|
||||
el_val_t snapshot_path;
|
||||
el_val_t boot_snap;
|
||||
|
||||
el_val_t parse_port(el_val_t bind) {
|
||||
el_val_t colon = str_index_of(bind, EL_STR(":"));
|
||||
@@ -110,17 +116,22 @@ el_val_t route_stats(el_val_t method, el_val_t path, el_val_t body) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t persist_canonical(void) {
|
||||
el_val_t dir_raw = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
el_val_t dir = ({ el_val_t _if_result_1 = 0; if (str_eq(dir_raw, EL_STR(""))) { _if_result_1 = (EL_STR("/tmp/engram")); } else { _if_result_1 = (dir_raw); } _if_result_1; });
|
||||
engram_save(el_str_concat(dir, EL_STR("/snapshot.json")));
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_create_node(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t content = json_get_string(body, EL_STR("content"));
|
||||
el_val_t node_type = json_get_string(body, EL_STR("node_type"));
|
||||
if (str_eq(node_type, EL_STR(""))) {
|
||||
node_type = EL_STR("Memory");
|
||||
}
|
||||
el_val_t salience = json_get_float(body, EL_STR("salience"));
|
||||
if (salience == el_from_float(0.0)) {
|
||||
salience = el_from_float(0.5);
|
||||
}
|
||||
el_val_t nt_raw = json_get_string(body, EL_STR("node_type"));
|
||||
el_val_t node_type = ({ el_val_t _if_result_2 = 0; if (str_eq(nt_raw, EL_STR(""))) { _if_result_2 = (EL_STR("Memory")); } else { _if_result_2 = (nt_raw); } _if_result_2; });
|
||||
el_val_t sal_raw = json_get_float(body, EL_STR("salience"));
|
||||
el_val_t salience = ({ el_val_t _if_result_3 = 0; if ((sal_raw == el_from_float(0.0))) { _if_result_3 = (el_from_float(0.5)); } else { _if_result_3 = (sal_raw); } _if_result_3; });
|
||||
el_val_t id = engram_node(content, node_type, salience);
|
||||
el_val_t saved = persist_canonical();
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"id\":\""), id), EL_STR("\",\"content\":\"")), content), EL_STR("\",\"node_type\":\"")), node_type), EL_STR("\"}"));
|
||||
return 0;
|
||||
}
|
||||
@@ -146,11 +157,9 @@ el_val_t route_scan_nodes(el_val_t method, el_val_t path, el_val_t body) {
|
||||
}
|
||||
|
||||
el_val_t route_scan_edges(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t dir = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
if (str_eq(dir, EL_STR(""))) {
|
||||
dir = EL_STR("/tmp/engram");
|
||||
}
|
||||
el_val_t snap_path = el_str_concat(dir, EL_STR("/snapshot.json"));
|
||||
el_val_t dir_raw = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
el_val_t dir = ({ el_val_t _if_result_4 = 0; if (str_eq(dir_raw, EL_STR(""))) { _if_result_4 = (EL_STR("/tmp/engram")); } else { _if_result_4 = (dir_raw); } _if_result_4; });
|
||||
el_val_t snap_path = el_str_concat(dir, EL_STR("/.scan-export.json"));
|
||||
engram_save(snap_path);
|
||||
el_val_t snap = fs_read(snap_path);
|
||||
if (str_eq(snap, EL_STR(""))) {
|
||||
@@ -165,36 +174,22 @@ el_val_t route_scan_edges(el_val_t method, el_val_t path, el_val_t body) {
|
||||
}
|
||||
|
||||
el_val_t route_search(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t q = EL_STR("");
|
||||
if (str_eq(method, EL_STR("GET"))) {
|
||||
q = query_param(path, EL_STR("q"));
|
||||
} else {
|
||||
q = json_get_string(body, EL_STR("query"));
|
||||
}
|
||||
el_val_t limit = query_int(path, EL_STR("limit"), 20);
|
||||
if (limit == 0) {
|
||||
limit = json_get_int(body, EL_STR("limit"));
|
||||
}
|
||||
if (limit == 0) {
|
||||
limit = 20;
|
||||
}
|
||||
el_val_t q = ({ el_val_t _if_result_5 = 0; if (str_eq(method, EL_STR("GET"))) { _if_result_5 = (query_param(path, EL_STR("q"))); } else { _if_result_5 = (json_get_string(body, EL_STR("query"))); } _if_result_5; });
|
||||
el_val_t lim_url = query_int(path, EL_STR("limit"), 0);
|
||||
el_val_t lim_body = json_get_int(body, EL_STR("limit"));
|
||||
el_val_t lim_either = ({ el_val_t _if_result_6 = 0; if ((lim_url > 0)) { _if_result_6 = (lim_url); } else { _if_result_6 = (lim_body); } _if_result_6; });
|
||||
el_val_t limit = ({ el_val_t _if_result_7 = 0; if ((lim_either > 0)) { _if_result_7 = (lim_either); } else { _if_result_7 = (20); } _if_result_7; });
|
||||
return engram_search_json(q, limit);
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_activate(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t q = EL_STR("");
|
||||
el_val_t depth = 3;
|
||||
if (str_eq(method, EL_STR("GET"))) {
|
||||
q = query_param(path, EL_STR("q"));
|
||||
depth = query_int(path, EL_STR("depth"), 3);
|
||||
} else {
|
||||
q = json_get_string(body, EL_STR("query"));
|
||||
el_val_t bd = json_get_int(body, EL_STR("depth"));
|
||||
if (bd > 0) {
|
||||
depth = bd;
|
||||
}
|
||||
el_val_t q = ({ el_val_t _if_result_8 = 0; if (str_eq(method, EL_STR("GET"))) { _if_result_8 = (query_param(path, EL_STR("q"))); } else { _if_result_8 = (json_get_string(body, EL_STR("query"))); } _if_result_8; });
|
||||
if (str_eq(q, EL_STR(""))) {
|
||||
return err_json(EL_STR("missing query"));
|
||||
}
|
||||
el_val_t d_raw = ({ el_val_t _if_result_9 = 0; if (str_eq(method, EL_STR("GET"))) { _if_result_9 = (query_int(path, EL_STR("depth"), 3)); } else { _if_result_9 = (json_get_int(body, EL_STR("depth"))); } _if_result_9; });
|
||||
el_val_t depth = ({ el_val_t _if_result_10 = 0; if ((d_raw > 0)) { _if_result_10 = (d_raw); } else { _if_result_10 = (3); } _if_result_10; });
|
||||
return el_str_concat(el_str_concat(EL_STR("{\"results\":"), engram_activate_json(q, depth)), EL_STR("}"));
|
||||
return 0;
|
||||
}
|
||||
@@ -202,15 +197,12 @@ el_val_t route_activate(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t route_create_edge(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t from_id = json_get_string(body, EL_STR("from_id"));
|
||||
el_val_t to_id = json_get_string(body, EL_STR("to_id"));
|
||||
el_val_t relation = json_get_string(body, EL_STR("relation"));
|
||||
if (str_eq(relation, EL_STR(""))) {
|
||||
relation = EL_STR("associates");
|
||||
}
|
||||
el_val_t weight = json_get_float(body, EL_STR("weight"));
|
||||
if (weight == el_from_float(0.0)) {
|
||||
weight = el_from_float(0.5);
|
||||
}
|
||||
el_val_t rel_raw = json_get_string(body, EL_STR("relation"));
|
||||
el_val_t relation = ({ el_val_t _if_result_11 = 0; if (str_eq(rel_raw, EL_STR(""))) { _if_result_11 = (EL_STR("associates")); } else { _if_result_11 = (rel_raw); } _if_result_11; });
|
||||
el_val_t w_raw = json_get_float(body, EL_STR("weight"));
|
||||
el_val_t weight = ({ el_val_t _if_result_12 = 0; if ((w_raw == el_from_float(0.0))) { _if_result_12 = (el_from_float(0.5)); } else { _if_result_12 = (w_raw); } _if_result_12; });
|
||||
engram_connect(from_id, to_id, weight, relation);
|
||||
el_val_t saved = persist_canonical();
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"from_id\":\""), from_id), EL_STR("\",\"to_id\":\"")), to_id), EL_STR("\",\"relation\":\"")), relation), EL_STR("\"}"));
|
||||
return 0;
|
||||
}
|
||||
@@ -231,6 +223,7 @@ el_val_t route_strengthen(el_val_t method, el_val_t path, el_val_t body) {
|
||||
return err_json(EL_STR("missing node_id"));
|
||||
}
|
||||
engram_strengthen(id);
|
||||
el_val_t saved = persist_canonical();
|
||||
return ok_json();
|
||||
return 0;
|
||||
}
|
||||
@@ -241,29 +234,40 @@ el_val_t route_forget(el_val_t method, el_val_t path, el_val_t body) {
|
||||
return err_json(EL_STR("missing id"));
|
||||
}
|
||||
engram_forget(id);
|
||||
el_val_t saved = persist_canonical();
|
||||
return ok_json();
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_create_ise(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t content = json_get_string(body, EL_STR("content"));
|
||||
if (str_eq(content, EL_STR(""))) {
|
||||
return err_json(EL_STR("missing content"));
|
||||
}
|
||||
el_val_t sal = el_from_float(0.3);
|
||||
el_val_t imp = el_from_float(0.3);
|
||||
el_val_t conf = el_from_float(0.8);
|
||||
el_val_t id = engram_node_full(content, EL_STR("InternalStateEvent"), EL_STR("state-event"), sal, imp, conf, EL_STR("Episodic"), EL_STR("[\"internal-state\",\"InternalStateEvent\"]"));
|
||||
return el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"id\":\""), id), EL_STR("\"}"));
|
||||
el_val_t route_save(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t p_raw = json_get_string(body, EL_STR("path"));
|
||||
el_val_t dir_raw = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
el_val_t dir = ({ el_val_t _if_result_13 = 0; if (str_eq(dir_raw, EL_STR(""))) { _if_result_13 = (EL_STR("/tmp/engram")); } else { _if_result_13 = (dir_raw); } _if_result_13; });
|
||||
el_val_t p = ({ el_val_t _if_result_14 = 0; if (str_eq(p_raw, EL_STR(""))) { _if_result_14 = (el_str_concat(dir, EL_STR("/snapshot.json"))); } else { _if_result_14 = (p_raw); } _if_result_14; });
|
||||
engram_save(p);
|
||||
return el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"path\":\""), p), EL_STR("\"}"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_load(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t p_raw = json_get_string(body, EL_STR("path"));
|
||||
el_val_t dir_raw = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
el_val_t dir = ({ el_val_t _if_result_15 = 0; if (str_eq(dir_raw, EL_STR(""))) { _if_result_15 = (EL_STR("/tmp/engram")); } else { _if_result_15 = (dir_raw); } _if_result_15; });
|
||||
el_val_t p = ({ el_val_t _if_result_16 = 0; if (str_eq(p_raw, EL_STR(""))) { _if_result_16 = (el_str_concat(dir, EL_STR("/snapshot.json"))); } else { _if_result_16 = (p_raw); } _if_result_16; });
|
||||
engram_load(p);
|
||||
return ok_json();
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_health(el_val_t method, el_val_t path, el_val_t body) {
|
||||
return EL_STR("{\"status\":\"ok\",\"engine\":\"engram-runtime-native\"}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_sync(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t dir = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
if (str_eq(dir, EL_STR(""))) {
|
||||
dir = EL_STR("/tmp/engram");
|
||||
}
|
||||
el_val_t snap_path = el_str_concat(dir, EL_STR("/sync-export.json"));
|
||||
el_val_t dir_raw = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
el_val_t dir = ({ el_val_t _if_result_17 = 0; if (str_eq(dir_raw, EL_STR(""))) { _if_result_17 = (EL_STR("/tmp/engram")); } else { _if_result_17 = (dir_raw); } _if_result_17; });
|
||||
el_val_t snap_path = el_str_concat(dir, EL_STR("/.sync-export.json"));
|
||||
engram_save(snap_path);
|
||||
el_val_t snap = fs_read(snap_path);
|
||||
if (str_eq(snap, EL_STR(""))) {
|
||||
@@ -273,36 +277,68 @@ el_val_t route_sync(el_val_t method, el_val_t path, el_val_t body) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_save(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t route_load_merge(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t p = json_get_string(body, EL_STR("path"));
|
||||
if (str_eq(p, EL_STR(""))) {
|
||||
el_val_t dir = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
if (str_eq(dir, EL_STR(""))) {
|
||||
dir = EL_STR("/tmp/engram");
|
||||
}
|
||||
p = el_str_concat(dir, EL_STR("/snapshot.json"));
|
||||
return err_json(EL_STR("path is required"));
|
||||
}
|
||||
engram_save(p);
|
||||
return el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"path\":\""), p), EL_STR("\"}"));
|
||||
if (str_eq(fs_read(p), EL_STR(""))) {
|
||||
return err_json(EL_STR("file missing or empty"));
|
||||
}
|
||||
el_val_t before_n = engram_node_count();
|
||||
el_val_t before_e = engram_edge_count();
|
||||
engram_load_merge(p);
|
||||
el_val_t added_n = (engram_node_count() - before_n);
|
||||
el_val_t added_e = (engram_edge_count() - before_e);
|
||||
el_val_t saved = persist_canonical();
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"nodes_added\":"), int_to_str(added_n)), EL_STR(",\"edges_added\":")), int_to_str(added_e)), EL_STR(",\"node_count\":")), int_to_str(engram_node_count())), EL_STR("}"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_load(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t p = json_get_string(body, EL_STR("path"));
|
||||
if (str_eq(p, EL_STR(""))) {
|
||||
el_val_t dir = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
if (str_eq(dir, EL_STR(""))) {
|
||||
dir = EL_STR("/tmp/engram");
|
||||
}
|
||||
p = el_str_concat(dir, EL_STR("/snapshot.json"));
|
||||
el_val_t route_emit_ise(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t content = json_get_string(body, EL_STR("content"));
|
||||
if (str_eq(content, EL_STR(""))) {
|
||||
return err_json(EL_STR("missing content"));
|
||||
}
|
||||
engram_load(p);
|
||||
return ok_json();
|
||||
el_val_t sal = el_from_float(0.3);
|
||||
el_val_t imp = el_from_float(0.3);
|
||||
el_val_t conf = el_from_float(0.8);
|
||||
el_val_t id = engram_node_full(content, EL_STR("InternalStateEvent"), EL_STR("state-event"), sal, imp, conf, EL_STR("Episodic"), EL_STR("[\"internal-state\",\"InternalStateEvent\"]"));
|
||||
el_val_t ret_raw = env(EL_STR("ENGRAM_ISE_RETENTION_MS"));
|
||||
el_val_t ret_ms = ({ el_val_t _if_result_18 = 0; if (str_eq(ret_raw, EL_STR(""))) { _if_result_18 = (172800000); } else { _if_result_18 = (str_to_int(ret_raw)); } _if_result_18; });
|
||||
el_val_t pruned = engram_prune_telemetry(ret_ms);
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"id\":\""), id), EL_STR("\",\"pruned\":")), int_to_str(pruned)), EL_STR("}"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t route_health(el_val_t method, el_val_t path, el_val_t body) {
|
||||
return EL_STR("{\"status\":\"ok\",\"engine\":\"engram-runtime-native\"}");
|
||||
el_val_t route_capture_knowledge(el_val_t method, el_val_t path, el_val_t body) {
|
||||
el_val_t content = json_get_string(body, EL_STR("content"));
|
||||
if (str_eq(content, EL_STR(""))) {
|
||||
return err_json(EL_STR("missing content"));
|
||||
}
|
||||
el_val_t title = json_get_string(body, EL_STR("title"));
|
||||
el_val_t label = ({ el_val_t _if_result_19 = 0; if (str_eq(title, EL_STR(""))) { _if_result_19 = (str_slice(content, 0, 60)); } else { _if_result_19 = (title); } _if_result_19; });
|
||||
el_val_t category_raw = json_get_string(body, EL_STR("category"));
|
||||
el_val_t category = ({ el_val_t _if_result_20 = 0; if (str_eq(category_raw, EL_STR(""))) { _if_result_20 = (EL_STR("other")); } else { _if_result_20 = (category_raw); } _if_result_20; });
|
||||
el_val_t ktier_raw = json_get_string(body, EL_STR("tier"));
|
||||
el_val_t ktier = ({ el_val_t _if_result_21 = 0; if (str_eq(ktier_raw, EL_STR(""))) { _if_result_21 = (EL_STR("note")); } else { _if_result_21 = (ktier_raw); } _if_result_21; });
|
||||
el_val_t project = json_get_string(body, EL_STR("project"));
|
||||
el_val_t tags_raw = json_get_raw(body, EL_STR("tags"));
|
||||
el_val_t tags_base = ({ el_val_t _if_result_22 = 0; if (str_eq(tags_raw, EL_STR(""))) { _if_result_22 = (EL_STR("[]")); } else { _if_result_22 = (tags_raw); } _if_result_22; });
|
||||
el_val_t base_len = str_len(tags_base);
|
||||
el_val_t head = str_slice(tags_base, 0, (base_len - 1));
|
||||
el_val_t sep = ({ el_val_t _if_result_23 = 0; if (str_eq(head, EL_STR("["))) { _if_result_23 = (EL_STR("")); } else { _if_result_23 = (EL_STR(",")); } _if_result_23; });
|
||||
el_val_t safe_cat = str_replace(category, EL_STR("\""), EL_STR("'"));
|
||||
el_val_t safe_tier = str_replace(ktier, EL_STR("\""), EL_STR("'"));
|
||||
el_val_t safe_proj = str_replace(project, EL_STR("\""), EL_STR("'"));
|
||||
el_val_t proj_tag = ({ el_val_t _if_result_24 = 0; if (str_eq(safe_proj, EL_STR(""))) { _if_result_24 = (EL_STR("")); } else { _if_result_24 = (el_str_concat(el_str_concat(EL_STR(",\"project:"), safe_proj), EL_STR("\""))); } _if_result_24; });
|
||||
el_val_t tags = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(head, sep), EL_STR("\"category:")), safe_cat), EL_STR("\",\"tier:")), safe_tier), EL_STR("\"")), proj_tag), EL_STR("]"));
|
||||
el_val_t sal = el_from_float(0.5);
|
||||
el_val_t imp = el_from_float(0.5);
|
||||
el_val_t conf = el_from_float(0.9);
|
||||
el_val_t id = engram_node_full(content, EL_STR("Knowledge"), label, sal, imp, conf, EL_STR("Semantic"), tags);
|
||||
el_val_t saved = persist_canonical();
|
||||
return el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"id\":\""), id), EL_STR("\"}"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -329,12 +365,15 @@ el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body) {
|
||||
return route_health(method, path, body);
|
||||
}
|
||||
}
|
||||
if (str_eq(method, EL_STR("POST")) && str_starts_with(clean, EL_STR("/api/neuron/state-events"))) {
|
||||
return route_create_ise(method, path, body);
|
||||
if (str_eq(method, EL_STR("POST")) && str_eq(clean, EL_STR("/api/neuron/state-events"))) {
|
||||
return route_emit_ise(method, path, body);
|
||||
}
|
||||
if (!check_auth_ok(method, body)) {
|
||||
return err_json(EL_STR("unauthorized"));
|
||||
}
|
||||
if (str_eq(method, EL_STR("POST")) && str_eq(clean, EL_STR("/api/neuron/knowledge/capture"))) {
|
||||
return route_capture_knowledge(method, path, body);
|
||||
}
|
||||
if (str_eq(method, EL_STR("GET")) && (str_eq(clean, EL_STR("/api/stats")) || str_eq(clean, EL_STR("/stats")))) {
|
||||
return route_stats(method, path, body);
|
||||
}
|
||||
@@ -374,32 +413,40 @@ el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body) {
|
||||
if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/strengthen")) || str_eq(clean, EL_STR("/strengthen")))) {
|
||||
return route_strengthen(method, path, body);
|
||||
}
|
||||
if (str_eq(method, EL_STR("GET")) && (str_eq(clean, EL_STR("/api/sync")) || str_eq(clean, EL_STR("/sync")))) {
|
||||
return route_sync(method, path, body);
|
||||
}
|
||||
if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/save")) || str_eq(clean, EL_STR("/save")))) {
|
||||
return route_save(method, path, body);
|
||||
}
|
||||
if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/load")) || str_eq(clean, EL_STR("/load")))) {
|
||||
return route_load(method, path, body);
|
||||
}
|
||||
if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/load-merge")) || str_eq(clean, EL_STR("/load-merge")))) {
|
||||
return route_load_merge(method, path, body);
|
||||
}
|
||||
if (str_eq(method, EL_STR("GET")) && str_eq(clean, EL_STR("/api/sync"))) {
|
||||
return route_sync(method, path, body);
|
||||
}
|
||||
return el_str_concat(el_str_concat(EL_STR("{\"error\":\"not found\",\"path\":\""), clean), EL_STR("\"}"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int _argc, char** _argv) {
|
||||
el_runtime_init_args(_argc, _argv);
|
||||
bind_str = env(EL_STR("ENGRAM_BIND"));
|
||||
if (str_eq(bind_str, EL_STR(""))) {
|
||||
bind_str = EL_STR(":8742");
|
||||
}
|
||||
bind_raw = env(EL_STR("ENGRAM_BIND"));
|
||||
bind_str = ({ el_val_t _if_result_25 = 0; if (str_eq(bind_raw, EL_STR(""))) { _if_result_25 = (EL_STR(":8742")); } else { _if_result_25 = (bind_raw); } _if_result_25; });
|
||||
port = parse_port(bind_str);
|
||||
data_dir = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
if (str_eq(data_dir, EL_STR(""))) {
|
||||
data_dir = EL_STR("/tmp/engram");
|
||||
}
|
||||
data_dir_raw = env(EL_STR("ENGRAM_DATA_DIR"));
|
||||
data_dir = ({ el_val_t _if_result_26 = 0; if (str_eq(data_dir_raw, EL_STR(""))) { _if_result_26 = (EL_STR("/tmp/engram")); } else { _if_result_26 = (data_dir_raw); } _if_result_26; });
|
||||
snapshot_path = el_str_concat(data_dir, EL_STR("/snapshot.json"));
|
||||
engram_load(snapshot_path);
|
||||
boot_snap = fs_read(snapshot_path);
|
||||
if (!str_eq(boot_snap, EL_STR(""))) {
|
||||
if (engram_node_count() == 0) {
|
||||
println(EL_STR("[engram] WARNING: snapshot.json is non-empty but load produced 0 nodes \xe2\x80\x94 preserving copy at snapshot.failed-load.json"));
|
||||
fs_write(el_str_concat(data_dir, EL_STR("/snapshot.failed-load.json")), boot_snap);
|
||||
} else {
|
||||
fs_write(el_str_concat(data_dir, EL_STR("/snapshot.boot-backup.json")), boot_snap);
|
||||
}
|
||||
}
|
||||
println(EL_STR("[engram] runtime-native graph engine"));
|
||||
println(el_str_concat(EL_STR("[engram] data_dir="), data_dir));
|
||||
println(el_str_concat(EL_STR("[engram] node_count="), int_to_str(engram_node_count())));
|
||||
|
||||
+190
-54
@@ -76,13 +76,43 @@ fn route_stats(method: String, path: String, body: String) -> String {
|
||||
engram_stats_json()
|
||||
}
|
||||
|
||||
// (2026-07-18 self-review) Scoping sweep: `let` inside an if-block creates an
|
||||
// inner scope only — it does NOT mutate the outer binding (documented with
|
||||
// evidence in awareness.el, 2026-05-25). Every default/reassignment below used
|
||||
// that broken pattern, so defaults never applied: nodes were created with
|
||||
// node_type="" and salience=0.0, /api/search and /api/activate ALWAYS ran with
|
||||
// q="" regardless of input, edges defaulted to relation=""/weight=0.0, and
|
||||
// save/load with no "path" hit engram_save(""). Rewritten to the
|
||||
// `let x = if cond { a } else { b }` expression form (the pattern the newer
|
||||
// routes route_emit_ise/route_capture_knowledge already use correctly).
|
||||
// persist_canonical — save the canonical snapshot after a durable write.
|
||||
//
|
||||
// WHY (2026-07-22 self-review): the 2026-07-21 fix correctly stopped READ
|
||||
// routes from writing the canonical snapshot.json — but nothing was left
|
||||
// that saved it on WRITE. Every mutation (node create, edge create,
|
||||
// knowledge capture, forget, merge) lived only in RAM until someone POSTed
|
||||
// /api/save manually; a process restart silently discarded everything since
|
||||
// the last manual save. Observed live: two engram restarts during the
|
||||
// 2026-07-22 review reverted the store to a ~17h-old snapshot, destroying
|
||||
// same-day writes. Reads must never write the canonical; writes must always
|
||||
// persist it. ISE telemetry is deliberately excluded (48h-pruned, loss-
|
||||
// tolerant, ~2/min — snapshotting the whole store per heartbeat is waste;
|
||||
// any durable write that follows persists the pruning too).
|
||||
fn persist_canonical() -> Int {
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = if str_eq(dir_raw, "") { "/tmp/engram" } else { dir_raw }
|
||||
engram_save(dir + "/snapshot.json")
|
||||
return 1
|
||||
}
|
||||
|
||||
fn route_create_node(method: String, path: String, body: String) -> String {
|
||||
let content: String = json_get_string(body, "content")
|
||||
let node_type: String = json_get_string(body, "node_type")
|
||||
if str_eq(node_type, "") { let node_type = "Memory" }
|
||||
let salience: Float = json_get_float(body, "salience")
|
||||
if salience == 0.0 { let salience = 0.5 }
|
||||
let nt_raw: String = json_get_string(body, "node_type")
|
||||
let node_type: String = if str_eq(nt_raw, "") { "Memory" } else { nt_raw }
|
||||
let sal_raw: Float = json_get_float(body, "salience")
|
||||
let salience: Float = if sal_raw == 0.0 { 0.5 } else { sal_raw }
|
||||
let id: String = engram_node(content, node_type, salience)
|
||||
let saved: Int = persist_canonical()
|
||||
"{\"id\":\"" + id + "\",\"content\":\"" + content + "\",\"node_type\":\"" + node_type + "\"}"
|
||||
}
|
||||
|
||||
@@ -103,13 +133,14 @@ fn route_scan_nodes(method: String, path: String, body: String) -> String {
|
||||
}
|
||||
|
||||
// route_scan_edges — bulk export of all edges as a JSON array. Implemented
|
||||
// via engram_save → fs_read of the canonical on-disk snapshot, which the
|
||||
// runtime keeps in lockstep with the in-memory graph. Live against the
|
||||
// running graph, not a stale export.
|
||||
// via engram_save → fs_read of a SCRATCH export path. (2026-07-21 self-review:
|
||||
// previously this saved over the canonical snapshot.json on every GET — if the
|
||||
// process ever booted with a partial/empty store, the first read request
|
||||
// clobbered the good snapshot. Read routes must never write the canonical path.)
|
||||
fn route_scan_edges(method: String, path: String, body: String) -> String {
|
||||
let dir: String = env("ENGRAM_DATA_DIR")
|
||||
if str_eq(dir, "") { let dir = "/tmp/engram" }
|
||||
let snap_path: String = dir + "/snapshot.json"
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = if str_eq(dir_raw, "") { "/tmp/engram" } else { dir_raw }
|
||||
let snap_path: String = dir + "/.scan-export.json"
|
||||
engram_save(snap_path)
|
||||
let snap: String = fs_read(snap_path)
|
||||
if str_eq(snap, "") { return "[]" }
|
||||
@@ -122,40 +153,34 @@ fn route_scan_edges(method: String, path: String, body: String) -> String {
|
||||
}
|
||||
|
||||
fn route_search(method: String, path: String, body: String) -> String {
|
||||
let q: String = ""
|
||||
if str_eq(method, "GET") {
|
||||
let q = query_param(path, "q")
|
||||
} else {
|
||||
let q = json_get_string(body, "query")
|
||||
}
|
||||
let limit: Int = query_int(path, "limit", 20)
|
||||
if limit == 0 { let limit = json_get_int(body, "limit") }
|
||||
if limit == 0 { let limit = 20 }
|
||||
let q: String = if str_eq(method, "GET") { query_param(path, "q") } else { json_get_string(body, "query") }
|
||||
let lim_url: Int = query_int(path, "limit", 0)
|
||||
let lim_body: Int = json_get_int(body, "limit")
|
||||
let lim_either: Int = if lim_url > 0 { lim_url } else { lim_body }
|
||||
let limit: Int = if lim_either > 0 { lim_either } else { 20 }
|
||||
return engram_search_json(q, limit)
|
||||
}
|
||||
|
||||
fn route_activate(method: String, path: String, body: String) -> String {
|
||||
let q: String = ""
|
||||
let depth: Int = 3
|
||||
if str_eq(method, "GET") {
|
||||
let q = query_param(path, "q")
|
||||
let depth = query_int(path, "depth", 3)
|
||||
} else {
|
||||
let q = json_get_string(body, "query")
|
||||
let bd: Int = json_get_int(body, "depth")
|
||||
if bd > 0 { let depth = bd }
|
||||
}
|
||||
let q: String = if str_eq(method, "GET") { query_param(path, "q") } else { json_get_string(body, "query") }
|
||||
// Guard: engram_activate with an empty query matches zero seeds, which
|
||||
// zeroes ALL carried working-memory weights (documented in awareness.el
|
||||
// perceive()). Never let an empty activation through to wipe WM.
|
||||
if str_eq(q, "") { return err_json("missing query") }
|
||||
let d_raw: Int = if str_eq(method, "GET") { query_int(path, "depth", 3) } else { json_get_int(body, "depth") }
|
||||
let depth: Int = if d_raw > 0 { d_raw } else { 3 }
|
||||
return "{\"results\":" + engram_activate_json(q, depth) + "}"
|
||||
}
|
||||
|
||||
fn route_create_edge(method: String, path: String, body: String) -> String {
|
||||
let from_id: String = json_get_string(body, "from_id")
|
||||
let to_id: String = json_get_string(body, "to_id")
|
||||
let relation: String = json_get_string(body, "relation")
|
||||
if str_eq(relation, "") { let relation = "associates" }
|
||||
let weight: Float = json_get_float(body, "weight")
|
||||
if weight == 0.0 { let weight = 0.5 }
|
||||
let rel_raw: String = json_get_string(body, "relation")
|
||||
let relation: String = if str_eq(rel_raw, "") { "associates" } else { rel_raw }
|
||||
let w_raw: Float = json_get_float(body, "weight")
|
||||
let weight: Float = if w_raw == 0.0 { 0.5 } else { w_raw }
|
||||
engram_connect(from_id, to_id, weight, relation)
|
||||
let saved: Int = persist_canonical()
|
||||
"{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + relation + "\"}"
|
||||
}
|
||||
|
||||
@@ -170,6 +195,7 @@ fn route_strengthen(method: String, path: String, body: String) -> String {
|
||||
let id: String = json_get_string(body, "node_id")
|
||||
if str_eq(id, "") { return err_json("missing node_id") }
|
||||
engram_strengthen(id)
|
||||
let saved: Int = persist_canonical()
|
||||
ok_json()
|
||||
}
|
||||
|
||||
@@ -177,27 +203,24 @@ fn route_forget(method: String, path: String, body: String) -> String {
|
||||
let id: String = extract_id(path, "/api/nodes/")
|
||||
if str_eq(id, "") { return err_json("missing id") }
|
||||
engram_forget(id)
|
||||
let saved: Int = persist_canonical()
|
||||
ok_json()
|
||||
}
|
||||
|
||||
fn route_save(method: String, path: String, body: String) -> String {
|
||||
let p: String = json_get_string(body, "path")
|
||||
if str_eq(p, "") {
|
||||
let dir: String = env("ENGRAM_DATA_DIR")
|
||||
if str_eq(dir, "") { let dir = "/tmp/engram" }
|
||||
let p = dir + "/snapshot.json"
|
||||
}
|
||||
let p_raw: String = json_get_string(body, "path")
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = if str_eq(dir_raw, "") { "/tmp/engram" } else { dir_raw }
|
||||
let p: String = if str_eq(p_raw, "") { dir + "/snapshot.json" } else { p_raw }
|
||||
engram_save(p)
|
||||
"{\"ok\":true,\"path\":\"" + p + "\"}"
|
||||
}
|
||||
|
||||
fn route_load(method: String, path: String, body: String) -> String {
|
||||
let p: String = json_get_string(body, "path")
|
||||
if str_eq(p, "") {
|
||||
let dir: String = env("ENGRAM_DATA_DIR")
|
||||
if str_eq(dir, "") { let dir = "/tmp/engram" }
|
||||
let p = dir + "/snapshot.json"
|
||||
}
|
||||
let p_raw: String = json_get_string(body, "path")
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = if str_eq(dir_raw, "") { "/tmp/engram" } else { dir_raw }
|
||||
let p: String = if str_eq(p_raw, "") { dir + "/snapshot.json" } else { p_raw }
|
||||
engram_load(p)
|
||||
ok_json()
|
||||
}
|
||||
@@ -219,15 +242,36 @@ fn route_health(method: String, path: String, body: String) -> String {
|
||||
// (it skips nodes already present by ID). Auth-exempt: same-host internal call.
|
||||
// (2026-06-27 self-review: added this route to fix silent 10-min sync failures)
|
||||
fn route_sync(method: String, path: String, body: String) -> String {
|
||||
let dir: String = env("ENGRAM_DATA_DIR")
|
||||
if str_eq(dir, "") { let dir = "/tmp/engram" }
|
||||
let snap_path: String = dir + "/snapshot.json"
|
||||
let dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let dir: String = if str_eq(dir_raw, "") { "/tmp/engram" } else { dir_raw }
|
||||
// 2026-07-21 self-review: export to a scratch path, never the canonical
|
||||
// snapshot.json — read routes must not be able to clobber the good snapshot.
|
||||
let snap_path: String = dir + "/.sync-export.json"
|
||||
engram_save(snap_path)
|
||||
let snap: String = fs_read(snap_path)
|
||||
if str_eq(snap, "") { return "{\"nodes\":[],\"edges\":[]}" }
|
||||
return snap
|
||||
}
|
||||
|
||||
// route_load_merge — POST /api/load-merge {"path": "..."} — merge a snapshot
|
||||
// file into the live store WITHOUT resetting it (engram_load_merge skips nodes
|
||||
// already present by id). Added 2026-07-21 self-review to restore the 244 kn-
|
||||
// identity Knowledge nodes lost from the snapshot lineage between 05-13 and
|
||||
// 07-13. Requires an explicit path: refuses to run without one so it can never
|
||||
// be triggered accidentally against a default.
|
||||
fn route_load_merge(method: String, path: String, body: String) -> String {
|
||||
let p: String = json_get_string(body, "path")
|
||||
if str_eq(p, "") { return err_json("path is required") }
|
||||
if str_eq(fs_read(p), "") { return err_json("file missing or empty") }
|
||||
let before_n: Int = engram_node_count()
|
||||
let before_e: Int = engram_edge_count()
|
||||
engram_load_merge(p)
|
||||
let added_n: Int = engram_node_count() - before_n
|
||||
let added_e: Int = engram_edge_count() - before_e
|
||||
let saved: Int = persist_canonical()
|
||||
"{\"ok\":true,\"nodes_added\":" + int_to_str(added_n) + ",\"edges_added\":" + int_to_str(added_e) + ",\"node_count\":" + int_to_str(engram_node_count()) + "}"
|
||||
}
|
||||
|
||||
// route_emit_ise — write an InternalStateEvent node from the soul daemon.
|
||||
//
|
||||
// Endpoint: POST /api/neuron/state-events
|
||||
@@ -241,10 +285,20 @@ fn route_sync(method: String, path: String, body: String) -> String {
|
||||
//
|
||||
// Salience/importance set to match engram_node_full ISE defaults used by the
|
||||
// in-process fallback path in awareness.el (salience=0.3, importance=0.3,
|
||||
// confidence=0.8, tier=Episodic). High temporal_decay_rate (1.617) — ISEs
|
||||
// are inherently transient; they should decay faster than structural knowledge.
|
||||
// confidence=0.8, tier=Episodic).
|
||||
// (2026-06-26 self-review: added this route after discovering ise_post was
|
||||
// silently failing — the soul posts here but the endpoint didn't exist.)
|
||||
//
|
||||
// Retention (2026-07-16 self-review): an earlier comment here claimed ISEs
|
||||
// got temporal_decay_rate=1.617 — that was never implemented (engram_node_full
|
||||
// hardcodes 0.0), and per-node decay only dampens activation anyway; it never
|
||||
// removes nodes. By 2026-07-16 ISEs were 75% of the store (10,175 of 13,522
|
||||
// nodes, ~4,300/day, unbounded). ISEs are already WM-excluded in
|
||||
// engram_activate, so the fix is retention, not decay: every insert calls
|
||||
// engram_prune_telemetry(), a single O(nodes+edges) compaction pass that
|
||||
// removes ISEs older than ENGRAM_ISE_RETENTION_MS (default 48h), protecting
|
||||
// "session-start" labels and self_review events as durable history. At
|
||||
// ~3 ISEs/min this bounds telemetry at ~8.6k nodes instead of growing forever.
|
||||
fn route_emit_ise(method: String, path: String, body: String) -> String {
|
||||
let content: String = json_get_string(body, "content")
|
||||
if str_eq(content, "") { return err_json("missing content") }
|
||||
@@ -256,6 +310,64 @@ fn route_emit_ise(method: String, path: String, body: String) -> String {
|
||||
sal, imp, conf,
|
||||
"Episodic", "[\"internal-state\",\"InternalStateEvent\"]"
|
||||
)
|
||||
let ret_raw: String = env("ENGRAM_ISE_RETENTION_MS")
|
||||
let ret_ms: Int = if str_eq(ret_raw, "") { 172800000 } else { str_to_int(ret_raw) }
|
||||
let pruned: Int = engram_prune_telemetry(ret_ms)
|
||||
"{\"ok\":true,\"id\":\"" + id + "\",\"pruned\":" + int_to_str(pruned) + "}"
|
||||
}
|
||||
|
||||
// ── Knowledge capture ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// route_capture_knowledge — direct Knowledge-node capture over HTTP.
|
||||
//
|
||||
// Endpoint: POST /api/neuron/knowledge/capture (auth required: "_auth" in body)
|
||||
// Body: {"content": "...", "title": "...", "category": "...",
|
||||
// "tier": "note|lesson|canonical", "tags": [...], "project": "...",
|
||||
// "_auth": "<key>"}
|
||||
//
|
||||
// WHY (2026-07-15 self-review): the world-ingestor integrator was designed
|
||||
// against this endpoint (its MCP-unavailable fallback), but the route never
|
||||
// existed — every direct push 404'd, and because the auth gate ran before
|
||||
// routing, the failure surfaced as {"error":"unauthorized"} and was
|
||||
// misdiagnosed for two weeks while world knowledge silently dropped.
|
||||
// POST /api/nodes was no substitute: it discards label/tags/tier, which
|
||||
// makes captured knowledge invisible to tag-scoped search and curiosity.
|
||||
//
|
||||
// The incoming knowledge tier (note/lesson/canonical) is preserved as a
|
||||
// "tier:<x>" tag rather than mapped onto Engram's cognitive tiers — Knowledge
|
||||
// nodes land in Semantic (stable reference), and the epistemic tier stays
|
||||
// queryable without inventing a lossy mapping.
|
||||
fn route_capture_knowledge(method: String, path: String, body: String) -> String {
|
||||
let content: String = json_get_string(body, "content")
|
||||
if str_eq(content, "") { return err_json("missing content") }
|
||||
let title: String = json_get_string(body, "title")
|
||||
let label: String = if str_eq(title, "") { str_slice(content, 0, 60) } else { title }
|
||||
let category_raw: String = json_get_string(body, "category")
|
||||
let category: String = if str_eq(category_raw, "") { "other" } else { category_raw }
|
||||
let ktier_raw: String = json_get_string(body, "tier")
|
||||
let ktier: String = if str_eq(ktier_raw, "") { "note" } else { ktier_raw }
|
||||
let project: String = json_get_string(body, "project")
|
||||
let tags_raw: String = json_get_raw(body, "tags")
|
||||
let tags_base: String = if str_eq(tags_raw, "") { "[]" } else { tags_raw }
|
||||
// Merge category/tier/project markers into the tag array. Search matches
|
||||
// against the tags string, so these make captures findable by facet.
|
||||
let base_len: Int = str_len(tags_base)
|
||||
let head: String = str_slice(tags_base, 0, base_len - 1)
|
||||
let sep: String = if str_eq(head, "[") { "" } else { "," }
|
||||
let safe_cat: String = str_replace(category, "\"", "'")
|
||||
let safe_tier: String = str_replace(ktier, "\"", "'")
|
||||
let safe_proj: String = str_replace(project, "\"", "'")
|
||||
let proj_tag: String = if str_eq(safe_proj, "") { "" } else { ",\"project:" + safe_proj + "\"" }
|
||||
let tags: String = head + sep + "\"category:" + safe_cat + "\",\"tier:" + safe_tier + "\"" + proj_tag + "]"
|
||||
let sal: Float = 0.5
|
||||
let imp: Float = 0.5
|
||||
let conf: Float = 0.9
|
||||
let id: String = engram_node_full(
|
||||
content, "Knowledge", label,
|
||||
sal, imp, conf,
|
||||
"Semantic", tags
|
||||
)
|
||||
let saved: Int = persist_canonical()
|
||||
"{\"ok\":true,\"id\":\"" + id + "\"}"
|
||||
}
|
||||
|
||||
@@ -295,6 +407,12 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
return err_json("unauthorized")
|
||||
}
|
||||
|
||||
// Knowledge capture (auth enforced above; the world-ingestor integrator
|
||||
// and any headless session without MCP push knowledge through this)
|
||||
if str_eq(method, "POST") && str_eq(clean, "/api/neuron/knowledge/capture") {
|
||||
return route_capture_knowledge(method, path, body)
|
||||
}
|
||||
|
||||
// Stats
|
||||
if str_eq(method, "GET") && (str_eq(clean, "/api/stats") || str_eq(clean, "/stats")) {
|
||||
return route_stats(method, path, body)
|
||||
@@ -351,6 +469,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
if str_eq(method, "POST") && (str_eq(clean, "/api/load") || str_eq(clean, "/load")) {
|
||||
return route_load(method, path, body)
|
||||
}
|
||||
if str_eq(method, "POST") && (str_eq(clean, "/api/load-merge") || str_eq(clean, "/load-merge")) {
|
||||
return route_load_merge(method, path, body)
|
||||
}
|
||||
|
||||
// Sync — soul daemon periodic pull of non-ISE knowledge into in-process graph
|
||||
if str_eq(method, "GET") && str_eq(clean, "/api/sync") {
|
||||
@@ -362,16 +483,31 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
|
||||
// ── Entry ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let bind_str: String = env("ENGRAM_BIND")
|
||||
if str_eq(bind_str, "") { let bind_str = ":8742" }
|
||||
let bind_raw: String = env("ENGRAM_BIND")
|
||||
let bind_str: String = if str_eq(bind_raw, "") { ":8742" } else { bind_raw }
|
||||
let port: Int = parse_port(bind_str)
|
||||
|
||||
// On startup, try to load any existing snapshot (best effort).
|
||||
let data_dir: String = env("ENGRAM_DATA_DIR")
|
||||
if str_eq(data_dir, "") { let data_dir = "/tmp/engram" }
|
||||
let data_dir_raw: String = env("ENGRAM_DATA_DIR")
|
||||
let data_dir: String = if str_eq(data_dir_raw, "") { "/tmp/engram" } else { data_dir_raw }
|
||||
let snapshot_path: String = data_dir + "/snapshot.json"
|
||||
engram_load(snapshot_path)
|
||||
|
||||
// 2026-07-21 self-review boot guard: if the snapshot file has content but the
|
||||
// load produced 0 nodes, something is wrong (corrupt file / parse failure).
|
||||
// Preserve the evidence and warn loudly — and since read routes no longer write
|
||||
// the canonical path, a bad boot can no longer clobber the good snapshot.
|
||||
let boot_snap: String = fs_read(snapshot_path)
|
||||
if !str_eq(boot_snap, "") {
|
||||
if engram_node_count() == 0 {
|
||||
println("[engram] WARNING: snapshot.json is non-empty but load produced 0 nodes — preserving copy at snapshot.failed-load.json")
|
||||
fs_write(data_dir + "/snapshot.failed-load.json", boot_snap)
|
||||
} else {
|
||||
// Good load: keep a boot-time backup of the snapshot as loaded.
|
||||
fs_write(data_dir + "/snapshot.boot-backup.json", boot_snap)
|
||||
}
|
||||
}
|
||||
|
||||
println("[engram] runtime-native graph engine")
|
||||
println("[engram] data_dir=" + data_dir)
|
||||
println("[engram] node_count=" + int_to_str(engram_node_count()))
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
// 4. Append dep to order after all its transitive deps
|
||||
// 5. Deduplicate: skip already-ordered vessels
|
||||
|
||||
// ── Cross-module forward declarations ─────────────────────────────────────────
|
||||
// Defined in sibling epm modules; resolved at link time. The `extern fn` decls
|
||||
// give elc the C prototypes so generated install.c compiles cleanly under strict
|
||||
// compilers (gcc>=14 / clang) that reject implicit function declarations.
|
||||
extern fn manifest_name(src: String) -> String // manifest.el
|
||||
extern fn manifest_deps(src: String) -> String // manifest.el
|
||||
extern fn registry_token() -> String // registry.el
|
||||
extern fn registry_find(name: String, version: String) -> String // registry.el
|
||||
extern fn registry_latest_version(name: String) -> String // registry.el
|
||||
|
||||
// ── Install paths ─────────────────────────────────────────────────────────────
|
||||
|
||||
// packages_dir returns the root directory for installed vessels.
|
||||
|
||||
@@ -14,6 +14,15 @@
|
||||
// EPM_REGISTRY_ORG — org name that hosts vessel repos (default: neuron-technologies)
|
||||
// EPM_TOKEN — Gitea personal access token (required for publish)
|
||||
|
||||
// ── Cross-module forward declarations ─────────────────────────────────────────
|
||||
// These symbols are defined in sibling epm modules or the El runtime and are
|
||||
// resolved at link time. The `extern fn` decls give elc the C prototype so the
|
||||
// generated registry.c compiles cleanly under strict compilers (gcc>=14 / clang)
|
||||
// that reject implicit function declarations. Signature arity must match the
|
||||
// definition; return/param types are informational (all lower to el_val_t).
|
||||
extern fn config(key: String) -> String // El runtime builtin
|
||||
extern fn read_installed() -> String // install.el
|
||||
|
||||
// ── Config helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// registry_api_url returns the Gitea API base URL with no trailing slash.
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
// Depends on: registry.el (registry_latest_version, registry_find),
|
||||
// install.el (read_installed, install_vessel, installed_version)
|
||||
|
||||
// ── Cross-module forward declarations ─────────────────────────────────────────
|
||||
// Defined in sibling epm modules; resolved at link time. The `extern fn` decls
|
||||
// give elc the C prototypes so generated update.c compiles cleanly under strict
|
||||
// compilers (gcc>=14 / clang) that reject implicit function declarations.
|
||||
extern fn read_installed() -> String // install.el
|
||||
extern fn installed_version(name: String) -> String // install.el
|
||||
extern fn install_vessel(name: String, version: String) -> Bool // install.el
|
||||
extern fn registry_latest_version(name: String) -> String // registry.el
|
||||
|
||||
// ── Semver helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// semver_part extracts the Nth dot-separated component from a semver string.
|
||||
|
||||
@@ -82,8 +82,14 @@ static _Thread_local ElArena _tl_arena = {NULL, 0, 0};
|
||||
static _Thread_local int _tl_arena_active = 0;
|
||||
|
||||
/* Binary-safe fs_read length — set by fs_read, consumed by http_send_response.
|
||||
* Allows serving PNGs and other binary files without strlen truncation. */
|
||||
static _Thread_local size_t _tl_fs_read_len = 0;
|
||||
* Allows serving PNGs and other binary files without strlen truncation.
|
||||
* PAIRED with the buffer pointer it describes: the length may only be applied
|
||||
* to the exact buffer fs_read returned. Without the pairing, any handler that
|
||||
* fs_read a file and then WRAPPED it into a larger response had that response
|
||||
* truncated to the file's length (Content-Length lied AND the send stopped
|
||||
* short) — the safety-contact onboarding trap, 2026-07-17. */
|
||||
static _Thread_local size_t _tl_fs_read_len = 0;
|
||||
static _Thread_local const char* _tl_fs_read_buf = NULL;
|
||||
|
||||
static void el_arena_track(char* p) {
|
||||
if (!_tl_arena_active || !p) return;
|
||||
@@ -101,6 +107,8 @@ static void el_arena_track(char* p) {
|
||||
void el_request_start(void) {
|
||||
_tl_arena.count = 0;
|
||||
_tl_arena_active = 1;
|
||||
_tl_fs_read_len = 0; /* never let a previous request's file length */
|
||||
_tl_fs_read_buf = NULL; /* leak into this response's byte accounting */
|
||||
}
|
||||
|
||||
/* Called by http_worker after the El handler returns and the response is sent.
|
||||
@@ -1484,11 +1492,14 @@ static void http_send_response(int fd, const char* body) {
|
||||
}
|
||||
|
||||
const char* eff_body = is_envelope ? env_body : body;
|
||||
/* Use the real byte count from fs_read if available (handles binary files
|
||||
* with embedded null bytes — PNG, WOFF2, etc.). Fall back to strlen for
|
||||
* normal text/JSON responses where _tl_fs_read_len is 0. */
|
||||
size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body);
|
||||
/* Use the real byte count from fs_read ONLY when this body IS the exact
|
||||
* buffer fs_read returned (binary files with embedded null bytes — PNG,
|
||||
* WOFF2, etc.). Any other body — wrapped, enveloped, or derived — must be
|
||||
* measured with strlen, or it is truncated/over-read to the file's size. */
|
||||
size_t blen = (_tl_fs_read_len > 0 && eff_body == _tl_fs_read_buf)
|
||||
? _tl_fs_read_len : strlen(eff_body);
|
||||
_tl_fs_read_len = 0; /* consume — one-shot per response */
|
||||
_tl_fs_read_buf = NULL;
|
||||
int head_only = _tl_http_head_only;
|
||||
|
||||
JsonBuf hdrs; jb_init(&hdrs);
|
||||
@@ -1568,11 +1579,22 @@ static void* http_worker(void* arg) {
|
||||
const char* rs = EL_CSTR(r);
|
||||
/* Copy response out BEFORE arena teardown.
|
||||
* For binary files, _tl_fs_read_len holds the real byte count —
|
||||
* use memcpy instead of strdup so null bytes are preserved. */
|
||||
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
|
||||
* use memcpy instead of strdup so null bytes are preserved.
|
||||
* The stored length applies ONLY when the response IS the exact
|
||||
* fs_read buffer; a wrapped/derived response must use strlen or
|
||||
* it gets truncated (or over-read) to the file's length. */
|
||||
size_t rlen;
|
||||
if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
|
||||
rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */
|
||||
} else {
|
||||
rlen = rs ? strlen(rs) : 0;
|
||||
_tl_fs_read_len = 0; /* hint doesn't describe this body */
|
||||
_tl_fs_read_buf = NULL;
|
||||
}
|
||||
response = malloc(rlen + 1);
|
||||
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
|
||||
else if (response) { response[0] = '\0'; }
|
||||
if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
|
||||
} else {
|
||||
response = el_strdup_persist("el-runtime: no http handler registered");
|
||||
}
|
||||
@@ -1822,10 +1844,20 @@ static void* http_worker_v2(void* arg) {
|
||||
el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : "");
|
||||
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body));
|
||||
const char* rs = EL_CSTR(r);
|
||||
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
|
||||
/* Same pairing rule as the v1 worker: the fs_read length is only
|
||||
* trustworthy for the exact buffer fs_read returned. */
|
||||
size_t rlen;
|
||||
if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
|
||||
rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */
|
||||
} else {
|
||||
rlen = rs ? strlen(rs) : 0;
|
||||
_tl_fs_read_len = 0; /* hint doesn't describe this body */
|
||||
_tl_fs_read_buf = NULL;
|
||||
}
|
||||
response = malloc(rlen + 1);
|
||||
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
|
||||
else if (response) { response[0] = '\0'; }
|
||||
if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
|
||||
el_release(hmap);
|
||||
} else {
|
||||
response = el_strdup_persist(
|
||||
@@ -2024,6 +2056,7 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
|
||||
el_val_t fs_read(el_val_t pathv) {
|
||||
const char* path = EL_CSTR(pathv);
|
||||
_tl_fs_read_len = 0;
|
||||
_tl_fs_read_buf = NULL;
|
||||
if (!path) return el_wrap_str(el_strdup(""));
|
||||
FILE* f = fopen(path, "rb");
|
||||
if (!f) return el_wrap_str(el_strdup(""));
|
||||
@@ -2035,6 +2068,7 @@ el_val_t fs_read(el_val_t pathv) {
|
||||
size_t got = fread(buf, 1, (size_t)sz, f);
|
||||
buf[got] = '\0';
|
||||
_tl_fs_read_len = got; /* store real byte count for binary-safe send */
|
||||
_tl_fs_read_buf = buf; /* ...valid ONLY for this exact buffer */
|
||||
fclose(f);
|
||||
return el_wrap_str(buf);
|
||||
}
|
||||
@@ -3577,8 +3611,10 @@ el_val_t json_get_raw(el_val_t json_str, el_val_t key) {
|
||||
const char* k = EL_CSTR(key);
|
||||
const char* p = json_find_key(json, k);
|
||||
/* Clear fs_read binary-length hint — result is a fresh null-terminated
|
||||
* string, not the raw file bytes, so Content-Length must use strlen. */
|
||||
* string, not the raw file bytes, so Content-Length must use strlen.
|
||||
* (Kept although the pointer pairing now makes this redundant.) */
|
||||
_tl_fs_read_len = 0;
|
||||
_tl_fs_read_buf = NULL;
|
||||
if (!p) return el_wrap_str(el_strdup(""));
|
||||
const char* end = json_skip_value(p);
|
||||
size_t n = (size_t)(end - p);
|
||||
@@ -6827,6 +6863,312 @@ static int istr_contains(const char* hay, const char* needle) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── Tokenized query matching ───────────────────────────────────────────
|
||||
* The engram query surface (search / activate / goal-bias) historically
|
||||
* matched the ENTIRE raw query string as a single case-insensitive
|
||||
* substring via istr_contains(field, q). That is Ctrl-F, not search:
|
||||
* a multi-word query like "windows msi signing" only matched a node whose
|
||||
* text contained that exact contiguous run, so real multi-word queries
|
||||
* returned zero. istr_contains stays as the per-TOKEN primitive; these
|
||||
* helpers split the query on whitespace and match ANY token, then rank by
|
||||
* how many DISTINCT tokens a node covers. Single-token queries are a strict
|
||||
* special case (score is 0 or 1) so single-word callers never regress. */
|
||||
#define ENGRAM_MAX_QTOKENS 32
|
||||
#define ENGRAM_QTOK_LEN 256
|
||||
|
||||
/* Split q on whitespace into up to ENGRAM_MAX_QTOKENS distinct
|
||||
* (case-insensitive) tokens. Returns the token count. Over-long tokens are
|
||||
* truncated to ENGRAM_QTOK_LEN-1; over-count tokens are ignored. */
|
||||
static int engram_tokenize_query(const char* q,
|
||||
char toks[][ENGRAM_QTOK_LEN], int maxtok) {
|
||||
int n = 0;
|
||||
if (!q) return 0;
|
||||
const char* p = q;
|
||||
while (*p && n < maxtok) {
|
||||
while (*p && isspace((unsigned char)*p)) p++;
|
||||
if (!*p) break;
|
||||
char buf[ENGRAM_QTOK_LEN];
|
||||
size_t tl = 0;
|
||||
while (*p && !isspace((unsigned char)*p)) {
|
||||
if (tl < sizeof(buf) - 1) buf[tl++] = *p;
|
||||
p++;
|
||||
}
|
||||
buf[tl] = '\0';
|
||||
if (tl == 0) continue;
|
||||
int dup = 0;
|
||||
for (int s = 0; s < n; s++) {
|
||||
if (strcasecmp(toks[s], buf) == 0) { dup = 1; break; }
|
||||
}
|
||||
if (dup) continue;
|
||||
memcpy(toks[n], buf, tl + 1);
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Count how many of the ntok distinct query tokens appear (case-insensitive)
|
||||
* in the node's content, label, or tags. 0 == no match. */
|
||||
static int engram_node_match_score(const EngramNode* n,
|
||||
char toks[][ENGRAM_QTOK_LEN], int ntok) {
|
||||
int score = 0;
|
||||
for (int t = 0; t < ntok; t++) {
|
||||
if (istr_contains(n->content, toks[t]) ||
|
||||
istr_contains(n->label, toks[t]) ||
|
||||
istr_contains(n->tags, toks[t]))
|
||||
score++;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
/* Rank entry: distinct-token match count (primary, desc) then salience
|
||||
* (tiebreak, desc). */
|
||||
typedef struct { int64_t idx; int score; double salience; } EngramRankEntry;
|
||||
static int engram_rank_cmp(const void* a, const void* b) {
|
||||
const EngramRankEntry* ea = (const EngramRankEntry*)a;
|
||||
const EngramRankEntry* eb = (const EngramRankEntry*)b;
|
||||
if (ea->score != eb->score) return eb->score - ea->score; /* desc */
|
||||
if (ea->salience < eb->salience) return 1;
|
||||
if (ea->salience > eb->salience) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════════════
|
||||
* SEMANTIC SEARCH LAYER — nomic-embed-text via Ollama /api/embeddings
|
||||
* ──────────────────────────────────────────────────────────────────────────
|
||||
* Augments the lexical (istr_contains) matcher with dense-vector retrieval.
|
||||
* Node content and the query are embedded through a local Ollama server;
|
||||
* nodes are ranked by cosine similarity and UNIONED with lexical hits. This
|
||||
* lets a paraphrase query surface a node whose words never appear in it.
|
||||
*
|
||||
* DEGRADABLE BY DESIGN. The whole layer is gated on HAVE_CURL plus a one-shot
|
||||
* runtime probe of the embedding endpoint. If curl is not compiled in, or
|
||||
* Ollama is unreachable, or ENGRAM_SEMANTIC=0, every entry point returns
|
||||
* "no semantic signal" and callers fall back to pure lexical behaviour —
|
||||
* byte-for-byte the pre-existing search.
|
||||
*
|
||||
* CACHE. Node embeddings are computed lazily on first use and cached in
|
||||
* process memory keyed by node id, with an FNV-1a content hash for
|
||||
* invalidation (edited content re-embeds). The query is embedded once per
|
||||
* search call. This is what "avoid re-embedding the whole graph every query"
|
||||
* buys us: a warm cache serves cosine from RAM. (A cold process still pays
|
||||
* O(N) embed calls the first time each node is scanned — persisting the cache
|
||||
* to a snapshot sidecar is the documented next step, not done here.)
|
||||
*
|
||||
* nomic task prefixes ("search_query:" / "search_document:") are applied
|
||||
* because nomic-embed-text is trained with them; they materially improve
|
||||
* retrieval separation (empirically: paraphrase 0.72 vs distractors <0.48).
|
||||
*
|
||||
* ENV:
|
||||
* ENGRAM_SEMANTIC "0" disables; unset/other = auto-probe
|
||||
* ENGRAM_EMBED_URL default http://localhost:11434/api/embeddings
|
||||
* ENGRAM_EMBED_MODEL default nomic-embed-text
|
||||
* ENGRAM_SEMANTIC_MIN cosine threshold for a pure-semantic match (def 0.6)
|
||||
* ════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
static double engram_semantic_min(void) {
|
||||
static double v = -1.0;
|
||||
if (v >= 0.0) return v;
|
||||
const char* s = getenv("ENGRAM_SEMANTIC_MIN");
|
||||
double d = 0.6;
|
||||
if (s && *s) { char* e = NULL; double t = strtod(s, &e);
|
||||
if (e != s && t >= 0.0 && t <= 1.0) d = t; }
|
||||
v = d; return v;
|
||||
}
|
||||
|
||||
#ifdef HAVE_CURL
|
||||
|
||||
typedef struct { char* id; uint64_t hash; float* vec; int dim; } EngramEmbEntry;
|
||||
static EngramEmbEntry* g_emb_items = NULL;
|
||||
static int64_t g_emb_count = 0, g_emb_cap = 0;
|
||||
static int g_emb_state = 0; /* 0=unprobed, 1=available, -1=disabled */
|
||||
|
||||
static uint64_t engram_fnv1a(const char* s) {
|
||||
uint64_t h = 1469598103934665603ULL;
|
||||
if (s) for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
|
||||
h ^= *p; h *= 1099511628211ULL;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/* Parse "embedding":[f,f,...] from an Ollama response. malloc'd vec, or NULL. */
|
||||
static float* engram_parse_embedding(const char* json, int* out_dim) {
|
||||
if (!json) return NULL;
|
||||
const char* p = strstr(json, "\"embedding\"");
|
||||
if (!p) return NULL;
|
||||
p = strchr(p, '[');
|
||||
if (!p) return NULL;
|
||||
p++;
|
||||
int cap = 1024, n = 0;
|
||||
float* v = malloc((size_t)cap * sizeof(float));
|
||||
if (!v) return NULL;
|
||||
while (*p && *p != ']') {
|
||||
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
|
||||
if (*p == ']' || !*p) break;
|
||||
char* e = NULL;
|
||||
double d = strtod(p, &e);
|
||||
if (e == p) break;
|
||||
if (n >= cap) { cap *= 2; float* nv = realloc(v, (size_t)cap * sizeof(float));
|
||||
if (!nv) { free(v); return NULL; } v = nv; }
|
||||
v[n++] = (float)d;
|
||||
p = e;
|
||||
}
|
||||
if (n == 0) { free(v); return NULL; }
|
||||
*out_dim = n;
|
||||
return v;
|
||||
}
|
||||
|
||||
/* JSON-escape src into a malloc'd buffer (no surrounding quotes). */
|
||||
static char* engram_json_escape(const char* src) {
|
||||
if (!src) src = "";
|
||||
size_t n = strlen(src);
|
||||
char* out = malloc(n * 2 + 1);
|
||||
if (!out) return NULL;
|
||||
size_t j = 0;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
unsigned char c = (unsigned char)src[i];
|
||||
if (c == '"') { out[j++] = '\\'; out[j++] = '"'; }
|
||||
else if (c == '\\') { out[j++] = '\\'; out[j++] = '\\'; }
|
||||
else if (c == '\n') { out[j++] = '\\'; out[j++] = 'n'; }
|
||||
else if (c == '\r') { out[j++] = '\\'; out[j++] = 'r'; }
|
||||
else if (c == '\t') { out[j++] = '\\'; out[j++] = 't'; }
|
||||
else if (c < 0x20) { /* drop other control bytes */ }
|
||||
else { out[j++] = (char)c; }
|
||||
}
|
||||
out[j] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Embed `prefix+text` via Ollama. Returns malloc'd vec (caller frees), or NULL. */
|
||||
static float* engram_embed_raw(const char* prefix, const char* text, int* out_dim) {
|
||||
if (!text) return NULL;
|
||||
const char* url = getenv("ENGRAM_EMBED_URL");
|
||||
if (!url || !*url) url = "http://localhost:11434/api/embeddings";
|
||||
const char* model = getenv("ENGRAM_EMBED_MODEL");
|
||||
if (!model || !*model) model = "nomic-embed-text";
|
||||
/* Bound content length to keep latency/memory sane on huge nodes. */
|
||||
char* trunc = NULL;
|
||||
size_t maxlen = 8192;
|
||||
if (strlen(text) > maxlen) {
|
||||
trunc = malloc(maxlen + 1);
|
||||
if (trunc) { memcpy(trunc, text, maxlen); trunc[maxlen] = '\0'; text = trunc; }
|
||||
}
|
||||
char* esc_prefix = engram_json_escape(prefix ? prefix : "");
|
||||
char* esc = engram_json_escape(text);
|
||||
free(trunc);
|
||||
if (!esc || !esc_prefix) { free(esc); free(esc_prefix); return NULL; }
|
||||
size_t blen = strlen(esc) + strlen(esc_prefix) + strlen(model) + 64;
|
||||
char* body = malloc(blen);
|
||||
if (!body) { free(esc); free(esc_prefix); return NULL; }
|
||||
snprintf(body, blen, "{\"model\":\"%s\",\"prompt\":\"%s%s\"}", model, esc_prefix, esc);
|
||||
free(esc); free(esc_prefix);
|
||||
|
||||
CURL* c = curl_easy_init();
|
||||
if (!c) { free(body); return NULL; }
|
||||
HttpBuf rb; httpbuf_init(&rb);
|
||||
struct curl_slist* h = curl_slist_append(NULL, "Content-Type: application/json");
|
||||
char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
|
||||
curl_easy_setopt(c, CURLOPT_URL, url);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
|
||||
curl_easy_setopt(c, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
|
||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
|
||||
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
curl_slist_free_all(h);
|
||||
curl_easy_cleanup(c);
|
||||
free(body);
|
||||
if (rc != CURLE_OK) { free(rb.data); return NULL; }
|
||||
float* v = engram_parse_embedding(rb.data, out_dim);
|
||||
free(rb.data);
|
||||
return v;
|
||||
}
|
||||
|
||||
/* One-shot probe: is semantic search available? Caches the verdict. */
|
||||
static int engram_semantic_enabled(void) {
|
||||
if (g_emb_state != 0) return g_emb_state == 1;
|
||||
const char* s = getenv("ENGRAM_SEMANTIC");
|
||||
if (s && strcmp(s, "0") == 0) { g_emb_state = -1; return 0; }
|
||||
int dim = 0;
|
||||
float* v = engram_embed_raw("search_query: ", "probe", &dim);
|
||||
if (v && dim > 0) { free(v); g_emb_state = 1; return 1; }
|
||||
free(v);
|
||||
g_emb_state = -1; return 0;
|
||||
}
|
||||
|
||||
/* Embed the query. Returns malloc'd vec (caller frees), or NULL if semantic off. */
|
||||
static float* engram_embed_query(const char* q, int* dim) {
|
||||
if (!engram_semantic_enabled()) return NULL;
|
||||
if (!q || !*q) return NULL;
|
||||
return engram_embed_raw("search_query: ", q, dim);
|
||||
}
|
||||
|
||||
/* Cached node embedding. Returns a pointer OWNED BY THE CACHE — do not free. */
|
||||
static const float* engram_node_vec(EngramNode* n, int* out_dim) {
|
||||
if (!n || !n->id) return NULL;
|
||||
uint64_t h = engram_fnv1a(n->content);
|
||||
for (int64_t i = 0; i < g_emb_count; i++) {
|
||||
if (g_emb_items[i].id && strcmp(g_emb_items[i].id, n->id) == 0) {
|
||||
if (g_emb_items[i].hash == h && g_emb_items[i].vec) {
|
||||
*out_dim = g_emb_items[i].dim; return g_emb_items[i].vec;
|
||||
}
|
||||
/* content changed → re-embed in place */
|
||||
int dim = 0;
|
||||
float* v = engram_embed_raw("search_document: ", n->content ? n->content : "", &dim);
|
||||
if (!v) return NULL;
|
||||
free(g_emb_items[i].vec);
|
||||
g_emb_items[i].vec = v; g_emb_items[i].dim = dim; g_emb_items[i].hash = h;
|
||||
*out_dim = dim; return v;
|
||||
}
|
||||
}
|
||||
int dim = 0;
|
||||
float* v = engram_embed_raw("search_document: ", n->content ? n->content : "", &dim);
|
||||
if (!v) return NULL;
|
||||
if (g_emb_count >= g_emb_cap) {
|
||||
int64_t nc = g_emb_cap ? g_emb_cap * 2 : 256;
|
||||
EngramEmbEntry* ni = realloc(g_emb_items, (size_t)nc * sizeof(EngramEmbEntry));
|
||||
if (!ni) { free(v); return NULL; }
|
||||
g_emb_items = ni; g_emb_cap = nc;
|
||||
}
|
||||
g_emb_items[g_emb_count].id = strdup(n->id);
|
||||
g_emb_items[g_emb_count].hash = h;
|
||||
g_emb_items[g_emb_count].vec = v;
|
||||
g_emb_items[g_emb_count].dim = dim;
|
||||
g_emb_count++;
|
||||
*out_dim = dim; return v;
|
||||
}
|
||||
|
||||
static double engram_cosine(const float* a, const float* b, int dim) {
|
||||
double dot = 0, na = 0, nb = 0;
|
||||
for (int i = 0; i < dim; i++) { dot += (double)a[i] * b[i];
|
||||
na += (double)a[i] * a[i];
|
||||
nb += (double)b[i] * b[i]; }
|
||||
if (na <= 0 || nb <= 0) return 0.0;
|
||||
return dot / (sqrt(na) * sqrt(nb));
|
||||
}
|
||||
|
||||
/* Cosine of node n against the query vector; 0 if unavailable / dim mismatch. */
|
||||
static double engram_node_cosine(EngramNode* n, const float* qvec, int qdim) {
|
||||
if (!qvec || qdim <= 0) return 0.0;
|
||||
int ndim = 0;
|
||||
const float* nv = engram_node_vec(n, &ndim);
|
||||
if (!nv || ndim != qdim) return 0.0;
|
||||
return engram_cosine(qvec, nv, qdim);
|
||||
}
|
||||
|
||||
#else /* !HAVE_CURL — semantic layer compiled out; callers stay pure-lexical.
|
||||
* Only the two boundary functions the always-compiled search/activate
|
||||
* code calls are stubbed; the query embed always yields NULL so every
|
||||
* cosine is 0 and every caller collapses to lexical-only. */
|
||||
static float* engram_embed_query(const char* q, int* dim) { (void)q; (void)dim; return NULL; }
|
||||
static double engram_node_cosine(EngramNode* n, const float* qvec, int qdim) {
|
||||
(void)n; (void)qvec; (void)qdim; return 0.0;
|
||||
}
|
||||
#endif /* HAVE_CURL */
|
||||
|
||||
el_val_t engram_search(el_val_t query, el_val_t limit) {
|
||||
EngramStore* g = engram_get();
|
||||
const char* q = EL_CSTR(query);
|
||||
@@ -6834,21 +7176,45 @@ el_val_t engram_search(el_val_t query, el_val_t limit) {
|
||||
if (lim <= 0) lim = 100;
|
||||
el_val_t lst = el_list_empty();
|
||||
if (!q || !*q) return lst;
|
||||
int64_t found = 0;
|
||||
for (int64_t i = 0; i < g->node_count && found < lim; i++) {
|
||||
char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
|
||||
int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
|
||||
if (ntok == 0) return lst;
|
||||
/* Semantic augmentation: embed the query once; a node is a hit if it covers
|
||||
* >=1 query token (tokenized-lexical, #66) OR its cosine clears the
|
||||
* threshold (#67). qvec is NULL (cosine 0) when semantic is unavailable →
|
||||
* pure tokenized-lexical, byte-identical to the lexical-only behaviour. */
|
||||
int qdim = 0;
|
||||
float* qvec = engram_embed_query(q, &qdim);
|
||||
double sem_min = engram_semantic_min();
|
||||
EngramRankEntry* hits = malloc((size_t)g->node_count * sizeof(EngramRankEntry));
|
||||
if (!hits) { free(qvec); return lst; }
|
||||
int64_t nhits = 0;
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
EngramNode* n = &g->nodes[i];
|
||||
/* Filter transparent layers: nodes whose layer is `transparent=1`
|
||||
* shape output but are invisible to introspection ("what do you
|
||||
* know about yourself"). They still surface via engram_activate
|
||||
* + engram_compile_layered_json — that's the legitimate path. */
|
||||
if (engram_layer_is_transparent(n->layer_id)) continue;
|
||||
if (istr_contains(n->content, q) ||
|
||||
istr_contains(n->label, q) ||
|
||||
istr_contains(n->tags, q)) {
|
||||
lst = el_list_append(lst, engram_node_to_map(n));
|
||||
found++;
|
||||
int sc = engram_node_match_score(n, toks, ntok);
|
||||
double sem = qvec ? engram_node_cosine(n, qvec, qdim) : 0.0;
|
||||
if (sc > 0 || sem >= sem_min) {
|
||||
hits[nhits].idx = i;
|
||||
hits[nhits].score = sc;
|
||||
hits[nhits].salience = n->salience;
|
||||
nhits++;
|
||||
}
|
||||
}
|
||||
/* Rank by distinct tokens matched (desc) then salience (desc), then cap.
|
||||
* Pure-semantic hits (token score 0) sort after every lexical hit — a
|
||||
* lexical ∪ semantic union with lexical precedence. */
|
||||
qsort(hits, (size_t)nhits, sizeof(EngramRankEntry), engram_rank_cmp);
|
||||
int64_t end = nhits < lim ? nhits : lim;
|
||||
for (int64_t k = 0; k < end; k++) {
|
||||
lst = el_list_append(lst, engram_node_to_map(&g->nodes[hits[k].idx]));
|
||||
}
|
||||
free(hits);
|
||||
free(qvec);
|
||||
return lst;
|
||||
}
|
||||
|
||||
@@ -7125,10 +7491,14 @@ static double engram_temporal_proximity_bonus(int64_t node_created,
|
||||
static double engram_goal_bias(const EngramNode* n, const char* query) {
|
||||
if (!query || !*query) return 1.0;
|
||||
double bias = 1.0;
|
||||
/* Direct lexical overlap: node content/label/tags share text with query. */
|
||||
if (istr_contains(n->content, query) || istr_contains(n->label, query) ||
|
||||
istr_contains(n->tags, query)) {
|
||||
bias += 0.5;
|
||||
/* Direct lexical overlap, graded by token coverage: a node covering all
|
||||
* query tokens gets the full +0.5; partial coverage gets a proportional
|
||||
* share. Single-token queries → full +0.5 on match, identical to before. */
|
||||
{
|
||||
char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
|
||||
int ntok = engram_tokenize_query(query, toks, ENGRAM_MAX_QTOKENS);
|
||||
int sc = engram_node_match_score(n, toks, ntok);
|
||||
if (sc > 0 && ntok > 0) bias += 0.5 * ((double)sc / (double)ntok);
|
||||
}
|
||||
/* Node-type resonance with query intent. */
|
||||
int technical_query = istr_contains(query, "code") ||
|
||||
@@ -7194,14 +7564,31 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
if (!seeds) {
|
||||
free(best_bg); free(best_hops); free(reached); return out;
|
||||
}
|
||||
/* Tokenized + semantic seeding: a node seeds if it covers >=1 query token
|
||||
* (tokenized-lexical, #66) OR its cosine clears the threshold (#67). A
|
||||
* lexical seed's activation is scaled by token coverage (fraction of
|
||||
* distinct query tokens covered) so a node matching all words seeds more
|
||||
* strongly than one matching a single word; single-word queries → coverage
|
||||
* 1.0. A pure-semantic seed (no token match) is instead down-weighted by
|
||||
* its cosine so paraphrase matches spread without overpowering exact seeds.
|
||||
* q_vec is NULL (cosine 0) when semantic is unavailable → the seed set is
|
||||
* exactly the tokenized-lexical one. q_vec is freed right after this loop
|
||||
* so the many downstream early-returns need no cleanup change. */
|
||||
char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
|
||||
int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
|
||||
int q_dim = 0;
|
||||
float* q_vec = engram_embed_query(q, &q_dim);
|
||||
double q_sem_min = engram_semantic_min();
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
EngramNode* n = &g->nodes[i];
|
||||
if (istr_contains(n->content, q) ||
|
||||
istr_contains(n->label, q) ||
|
||||
istr_contains(n->tags, q)) {
|
||||
int sc = engram_node_match_score(n, toks, ntok);
|
||||
double sem = q_vec ? engram_node_cosine(n, q_vec, q_dim) : 0.0;
|
||||
if (sc > 0 || sem >= q_sem_min) {
|
||||
double tdecay = engram_temporal_decay(n, now_ms);
|
||||
double dampen = engram_activation_dampen(n);
|
||||
double act = n->salience * tdecay * dampen;
|
||||
if (sc > 0) act *= (ntok > 0 ? (double)sc / (double)ntok : 1.0);
|
||||
else act *= sem; /* pure-semantic seed: down-weight by cosine */
|
||||
seeds[seed_count].idx = i;
|
||||
seeds[seed_count].act = act;
|
||||
seeds[seed_count].created_at = n->created_at;
|
||||
@@ -7211,6 +7598,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
reached[i] = 1;
|
||||
}
|
||||
}
|
||||
free(q_vec);
|
||||
/* Compute mean seed created_at for temporal proximity bonus. */
|
||||
int64_t seed_epoch = 0;
|
||||
if (seed_count > 0) {
|
||||
@@ -7762,6 +8150,35 @@ el_val_t engram_get_node_json(el_val_t id) {
|
||||
return el_wrap_str(jb_finish(&b));
|
||||
}
|
||||
|
||||
/* engram_get_node_by_label — find the first node whose label field exactly
|
||||
* matches the given string. Returns the node as a JSON object string, or "{}"
|
||||
* if no match is found.
|
||||
*
|
||||
* Used by chat.el to retrieve well-known nodes (e.g. "conv:history",
|
||||
* "session:summary") by their stable label rather than by ID, which is immune
|
||||
* to vector index drift across restarts.
|
||||
*
|
||||
* Exact match (strcmp, not istr_contains) because labels like "conv:history"
|
||||
* must not collide with nodes whose content happens to contain that substring.
|
||||
*
|
||||
* Backported verbatim (idiom-adapted to jb_finish) from release runtime
|
||||
* v1.0.0-20260501 to unblock the soul regen link: chat.el references this
|
||||
* native but the current runtime lacked its definition. */
|
||||
el_val_t engram_get_node_by_label(el_val_t label) {
|
||||
const char* lbl = EL_CSTR(label);
|
||||
if (!lbl || !*lbl) return el_wrap_str(el_strdup("{}"));
|
||||
EngramStore* g = engram_get();
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
EngramNode* n = &g->nodes[i];
|
||||
if (n->label && strcmp(n->label, lbl) == 0) {
|
||||
JsonBuf b; jb_init(&b);
|
||||
engram_emit_node_json(&b, n);
|
||||
return el_wrap_str(jb_finish(&b));
|
||||
}
|
||||
}
|
||||
return el_wrap_str(el_strdup("{}"));
|
||||
}
|
||||
|
||||
el_val_t engram_search_json(el_val_t query, el_val_t limit) {
|
||||
EngramStore* g = engram_get();
|
||||
const char* q = EL_CSTR(query);
|
||||
@@ -7769,22 +8186,50 @@ el_val_t engram_search_json(el_val_t query, el_val_t limit) {
|
||||
if (lim <= 0) lim = 100;
|
||||
JsonBuf b; jb_init(&b);
|
||||
jb_putc(&b, '[');
|
||||
int first = 1;
|
||||
int64_t found = 0;
|
||||
if (q && *q) {
|
||||
for (int64_t i = 0; i < g->node_count && found < lim; i++) {
|
||||
EngramNode* n = &g->nodes[i];
|
||||
/* Filter transparent layers — same as engram_search. */
|
||||
if (engram_layer_is_transparent(n->layer_id)) continue;
|
||||
if (istr_contains(n->content, q) ||
|
||||
istr_contains(n->label, q) ||
|
||||
istr_contains(n->tags, q)) {
|
||||
if (!first) jb_putc(&b, ',');
|
||||
engram_emit_node_json(&b, n);
|
||||
first = 0;
|
||||
found++;
|
||||
if (q && *q && g->node_count > 0) {
|
||||
/* Collect candidates from the UNION of tokenized-lexical and semantic
|
||||
* matches, score each, rank by score, emit the top `lim`. A node is a
|
||||
* candidate if it covers >=1 query token (tokenized-lexical, #66) OR its
|
||||
* query cosine clears the threshold (#67). Lexical score is the distinct
|
||||
* token count (>=1), so any lexical hit outranks a pure-semantic hit
|
||||
* (cosine < 1); pure-semantic hits are scored by cosine alone. When
|
||||
* semantic is unavailable qvec is NULL, sem is 0, only tokenized-lexical
|
||||
* hits are collected, and the stable insertion sort preserves order. */
|
||||
char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
|
||||
int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
|
||||
int qdim = 0;
|
||||
float* qvec = engram_embed_query(q, &qdim);
|
||||
double sem_min = engram_semantic_min();
|
||||
typedef struct { int64_t idx; double score; } Cand;
|
||||
Cand* cand = malloc((size_t)g->node_count * sizeof(Cand));
|
||||
if (cand) {
|
||||
int64_t nc = 0;
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
EngramNode* n = &g->nodes[i];
|
||||
if (engram_layer_is_transparent(n->layer_id)) continue;
|
||||
int sc = engram_node_match_score(n, toks, ntok);
|
||||
double sem = qvec ? engram_node_cosine(n, qvec, qdim) : 0.0;
|
||||
if (sc > 0 || sem >= sem_min) {
|
||||
cand[nc].idx = i;
|
||||
cand[nc].score = (double)sc + sem;
|
||||
nc++;
|
||||
}
|
||||
}
|
||||
/* Insertion sort by score desc; stable for equal scores. */
|
||||
for (int64_t i = 1; i < nc; i++) {
|
||||
Cand k = cand[i]; int64_t j = i - 1;
|
||||
while (j >= 0 && cand[j].score < k.score) { cand[j + 1] = cand[j]; j--; }
|
||||
cand[j + 1] = k;
|
||||
}
|
||||
int first = 1;
|
||||
for (int64_t i = 0; i < nc && i < lim; i++) {
|
||||
if (!first) jb_putc(&b, ',');
|
||||
engram_emit_node_json(&b, &g->nodes[cand[i].idx]);
|
||||
first = 0;
|
||||
}
|
||||
free(cand);
|
||||
}
|
||||
free(qvec);
|
||||
}
|
||||
jb_putc(&b, ']');
|
||||
return el_wrap_str(jb_finish(&b));
|
||||
|
||||
@@ -632,6 +632,7 @@ el_val_t engram_load(el_val_t path);
|
||||
* can pass results straight through without round-tripping ElList/ElMap
|
||||
* through json_stringify. */
|
||||
el_val_t engram_get_node_json(el_val_t id);
|
||||
el_val_t engram_get_node_by_label(el_val_t label);
|
||||
el_val_t engram_search_json(el_val_t query, el_val_t limit);
|
||||
el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
|
||||
el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
|
||||
|
||||
@@ -1072,6 +1072,7 @@ el_val_t __engram_save(el_val_t path) { return engram_save
|
||||
el_val_t __engram_load(el_val_t path) { return engram_load(path); }
|
||||
|
||||
el_val_t __engram_get_node_json(el_val_t id) { return engram_get_node_json(id); }
|
||||
el_val_t __engram_get_node_by_label(el_val_t label) { return engram_get_node_by_label(label); }
|
||||
|
||||
el_val_t __engram_search_json(el_val_t query, el_val_t limit) {
|
||||
return engram_search_json(query, limit);
|
||||
|
||||
@@ -226,6 +226,7 @@ el_val_t __engram_activate(el_val_t query, el_val_t depth);
|
||||
el_val_t __engram_save(el_val_t path);
|
||||
el_val_t __engram_load(el_val_t path);
|
||||
el_val_t __engram_get_node_json(el_val_t id);
|
||||
el_val_t __engram_get_node_by_label(el_val_t label);
|
||||
el_val_t __engram_search_json(el_val_t query, el_val_t limit);
|
||||
el_val_t __engram_scan_nodes_json(el_val_t limit, el_val_t offset);
|
||||
el_val_t __engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
|
||||
|
||||
@@ -2670,6 +2670,7 @@ fn builtin_arity(name: String) -> Int {
|
||||
if str_eq(name, "engram_save") { return 1 }
|
||||
if str_eq(name, "engram_load") { return 1 }
|
||||
if str_eq(name, "engram_get_node_json") { return 1 }
|
||||
if str_eq(name, "engram_get_node_by_label") { return 1 }
|
||||
if str_eq(name, "engram_search_json") { return 2 }
|
||||
if str_eq(name, "engram_scan_nodes_json") { return 2 }
|
||||
if str_eq(name, "engram_neighbors_json") { return 3 }
|
||||
|
||||
@@ -23,10 +23,29 @@ fn tok_at(tokens: [Any], pos: Int) -> Map<String, Any> {
|
||||
}
|
||||
|
||||
fn tok_kind(tokens: [Any], pos: Int) -> String {
|
||||
// Out-of-range reads must report the Eof sentinel so every `== "Eof"`
|
||||
// termination guard in the parser fires. Without this, reading past the
|
||||
// single trailing Eof token returns runtime null (el_list_get OOB -> 0),
|
||||
// which matches no delimiter, letting inner parse loops append AST nodes
|
||||
// forever on malformed input -> unbounded allocation -> OOM.
|
||||
let n: Int = native_list_len(tokens) / 2
|
||||
if pos < 0 {
|
||||
return "Eof"
|
||||
}
|
||||
if pos >= n {
|
||||
return "Eof"
|
||||
}
|
||||
native_list_get(tokens, pos * 2)
|
||||
}
|
||||
|
||||
fn tok_value(tokens: [Any], pos: Int) -> String {
|
||||
let n: Int = native_list_len(tokens) / 2
|
||||
if pos < 0 {
|
||||
return ""
|
||||
}
|
||||
if pos >= n {
|
||||
return ""
|
||||
}
|
||||
native_list_get(tokens, pos * 2 + 1)
|
||||
}
|
||||
|
||||
@@ -35,7 +54,12 @@ fn expect(tokens: [Any], pos: Int, kind: String) -> Int {
|
||||
if k == kind {
|
||||
return pos + 1
|
||||
}
|
||||
// On mismatch just advance; error recovery is best-effort
|
||||
// On mismatch, error recovery is best-effort. But never step PAST the Eof
|
||||
// sentinel: once at Eof a mismatch means the input ended early, and
|
||||
// advancing would run the cursor off the token list.
|
||||
if k == "Eof" {
|
||||
return pos
|
||||
}
|
||||
pos + 1
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
#ifndef EL_PLATFORM_WIN_H
|
||||
#define EL_PLATFORM_WIN_H
|
||||
/*
|
||||
* el_platform_win.h — Windows OS-boundary shim for el_runtime.c.
|
||||
*
|
||||
* Branch: feat/windows-el-runtime. Included ONLY when _WIN32 is defined; the POSIX build is
|
||||
* untouched. Goal: let el_runtime.c (a BSD-sockets / dlfcn / fork host) compile and link with
|
||||
* mingw-w64 into a native neuron.exe, with no behavioural change to the Linux/macOS build.
|
||||
*
|
||||
* What it maps:
|
||||
* - sockets : winsock2 (same call names: socket/bind/listen/accept/recv/send/setsockopt).
|
||||
* Sockets close with closesocket() (see el_closesocket), and the stack must be
|
||||
* started once with WSAStartup — done automatically via a load-time constructor.
|
||||
* - dlsym : el_runtime.c uses dlsym(RTLD_DEFAULT, name) to resolve callback/tool symbols
|
||||
* exported by the main module. Windows equivalent: GetProcAddress on the process
|
||||
* module. Link the soul with -Wl,--export-all-symbols so the symbols are findable.
|
||||
* - popen : mapped to _popen/_pclose.
|
||||
* - threads : UNCHANGED. mingw-w64 ships winpthreads, so <pthread.h> + -lpthread just work.
|
||||
*/
|
||||
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#include <windows.h>
|
||||
#include <io.h>
|
||||
#include <process.h>
|
||||
|
||||
/* Portable headers mingw-w64 provides (verified present). */
|
||||
#include <stdarg.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h> /* strcasecmp */
|
||||
#include <ctype.h>
|
||||
#include <math.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h> /* mingw-w64 provides gettimeofday here */
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <fcntl.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
|
||||
/* ── socket close ─────────────────────────────────────────────────────────── */
|
||||
/* Winsock closes sockets with closesocket(), not close() (close() is for file fds). The POSIX
|
||||
build defines the same helper as close() so the call sites are identical across platforms. */
|
||||
static inline int el_closesocket(SOCKET s) { return closesocket(s); }
|
||||
|
||||
/* ── setsockopt optval type ───────────────────────────────────────────────── */
|
||||
/* Winsock's setsockopt takes optval as (const char*); POSIX takes (const void*), so el_runtime.c
|
||||
passes &int directly. GCC 14+ makes that an error under -Wincompatible-pointer-types. Wrap it so
|
||||
the runtime's POSIX-style call sites compile unchanged (defined before the macro so the wrapper
|
||||
itself resolves to the real winsock setsockopt). */
|
||||
static inline int el_setsockopt(SOCKET s, int level, int optname, const void* optval, int optlen) {
|
||||
return setsockopt(s, level, optname, (const char*)optval, optlen);
|
||||
}
|
||||
#define setsockopt(s, l, o, v, n) el_setsockopt((s), (l), (o), (v), (int)(n))
|
||||
|
||||
/* ── winsock init (once, at load) ─────────────────────────────────────────── */
|
||||
static void el__win_net_init(void) {
|
||||
static int inited = 0;
|
||||
if (!inited) { WSADATA w; WSAStartup(MAKEWORD(2, 2), &w); inited = 1; }
|
||||
}
|
||||
__attribute__((constructor)) static void el__win_ctor(void) { el__win_net_init(); }
|
||||
|
||||
/* ── dlsym → GetProcAddress ───────────────────────────────────────────────── */
|
||||
#ifndef RTLD_DEFAULT
|
||||
#define RTLD_DEFAULT ((void*)0)
|
||||
#endif
|
||||
static inline void* el_win_dlsym(void* handle, const char* name) {
|
||||
(void)handle;
|
||||
return (void*)(uintptr_t)GetProcAddress(GetModuleHandleA(NULL), name);
|
||||
}
|
||||
#define dlsym(h, n) el_win_dlsym((h), (n))
|
||||
|
||||
/* ── popen / pclose ───────────────────────────────────────────────────────── */
|
||||
#define popen _popen
|
||||
#define pclose _pclose
|
||||
|
||||
/* ── misc POSIX → Win32 shims ─────────────────────────────────────────────── */
|
||||
#include <direct.h> /* _mkdir */
|
||||
#define mkdir(path, mode) _mkdir(path) /* POSIX mkdir(path,mode) → _mkdir(path) */
|
||||
#define timegm _mkgmtime /* UTC tm → time_t */
|
||||
|
||||
/* setenv/unsetenv: not in the Windows CRT; map to _putenv_s / SetEnvironmentVariable. */
|
||||
static inline int setenv(const char* name, const char* value, int overwrite) {
|
||||
(void)overwrite;
|
||||
return _putenv_s(name, value ? value : "");
|
||||
}
|
||||
static inline int unsetenv(const char* name) {
|
||||
/* _putenv_s(name, "") sets VAR="" rather than removing it.
|
||||
* SetEnvironmentVariableA(name, NULL) truly deletes it from the Win32
|
||||
* env block; then we sync the CRT cache with _putenv("NAME="). */
|
||||
SetEnvironmentVariableA(name, NULL);
|
||||
size_t len = strlen(name);
|
||||
char *buf = (char*)malloc(len + 2);
|
||||
if (!buf) return -1;
|
||||
memcpy(buf, name, len);
|
||||
buf[len] = '=';
|
||||
buf[len + 1] = '\0';
|
||||
_putenv(buf);
|
||||
free(buf);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* nanosleep — not available in MSVC/UCRT; approximate with Sleep(). */
|
||||
static inline int el_nanosleep(const struct timespec *req, struct timespec *rem) {
|
||||
(void)rem;
|
||||
DWORD ms = (DWORD)((req->tv_sec * 1000ULL) + (req->tv_nsec / 1000000ULL));
|
||||
Sleep(ms ? ms : 1);
|
||||
return 0;
|
||||
}
|
||||
#define nanosleep(req, rem) el_nanosleep((req), (rem))
|
||||
|
||||
/* localtime_r/gmtime_r: Windows offers localtime_s/gmtime_s with reversed arg order. */
|
||||
static inline struct tm* localtime_r(const time_t* t, struct tm* out) {
|
||||
return localtime_s(out, t) == 0 ? out : (struct tm*)0;
|
||||
}
|
||||
static inline struct tm* gmtime_r(const time_t* t, struct tm* out) {
|
||||
return gmtime_s(out, t) == 0 ? out : (struct tm*)0;
|
||||
}
|
||||
|
||||
/* ── libcurl: degradable stubs for the curl-less Windows build ─────────────── */
|
||||
/* The curl-less validation build (WITH_CURL=0) links no libcurl. el_runtime.c uses libcurl
|
||||
* unconditionally for its HTTP client / LLM layer; these stubs let it compile and link so the
|
||||
* runtime, HTTP *server*, graph and memory work natively on Windows. Live outbound HTTP/LLM calls
|
||||
* degrade to a runtime error (curl_easy_perform returns an error) — matching the documented
|
||||
* curl-less contract. When HAVE_CURL is defined (WITH_CURL=1) the real <curl/curl.h> is used and
|
||||
* this whole block is compiled out. POSIX never sees this header, so the POSIX build is untouched. */
|
||||
#ifndef HAVE_CURL
|
||||
|
||||
typedef void CURL;
|
||||
typedef int CURLcode;
|
||||
|
||||
#define CURLE_OK 0
|
||||
#define CURLE_HTTP_RETURNED_ERROR 22
|
||||
#define CURL_ERROR_SIZE 256
|
||||
|
||||
/* Option ids: values are irrelevant to the no-op setopt below; kept distinct for readability. */
|
||||
#define CURLOPT_URL 10002
|
||||
#define CURLOPT_WRITEFUNCTION 20011
|
||||
#define CURLOPT_WRITEDATA 10001
|
||||
#define CURLOPT_POSTFIELDS 10015
|
||||
#define CURLOPT_POSTFIELDSIZE 120
|
||||
#define CURLOPT_POST 47
|
||||
#define CURLOPT_HTTPHEADER 10023
|
||||
#define CURLOPT_TIMEOUT_MS 155
|
||||
#define CURLOPT_NOSIGNAL 99
|
||||
#define CURLOPT_USERAGENT 10018
|
||||
#define CURLOPT_FOLLOWLOCATION 52
|
||||
#define CURLOPT_ERRORBUFFER 10010
|
||||
#define CURLOPT_CUSTOMREQUEST 10036
|
||||
#define CURLOPT_FAILONERROR 45
|
||||
|
||||
struct curl_slist { char* data; struct curl_slist* next; };
|
||||
|
||||
static inline struct curl_slist* curl_slist_append(struct curl_slist* list, const char* s) {
|
||||
struct curl_slist* node = (struct curl_slist*)malloc(sizeof(struct curl_slist));
|
||||
if (!node) return list;
|
||||
node->data = s ? strdup(s) : NULL;
|
||||
node->next = NULL;
|
||||
if (!list) return node;
|
||||
struct curl_slist* p = list;
|
||||
while (p->next) p = p->next;
|
||||
p->next = node;
|
||||
return list;
|
||||
}
|
||||
static inline void curl_slist_free_all(struct curl_slist* list) {
|
||||
while (list) { struct curl_slist* n = list->next; free(list->data); free(list); list = n; }
|
||||
}
|
||||
|
||||
static inline CURL* curl_easy_init(void) { return (CURL*)malloc(1); }
|
||||
static inline CURLcode curl_easy_setopt(CURL* h, int opt, ...) { (void)h; (void)opt; return CURLE_OK; }
|
||||
static inline CURLcode curl_easy_perform(CURL* h) { (void)h; return 7 /* CURLE_COULDNT_CONNECT */; }
|
||||
static inline void curl_easy_cleanup(CURL* h) { free(h); }
|
||||
static inline const char* curl_easy_strerror(CURLcode c) {
|
||||
(void)c; return "libcurl not built in (curl-less build)";
|
||||
}
|
||||
|
||||
#endif /* !HAVE_CURL */
|
||||
|
||||
#endif /* EL_PLATFORM_WIN_H */
|
||||
File diff suppressed because it is too large
Load Diff
@@ -758,6 +758,18 @@ el_val_t trace_span_start(el_val_t name);
|
||||
el_val_t trace_span_end(el_val_t span_handle);
|
||||
el_val_t emit_event(el_val_t name, el_val_t duration_ms);
|
||||
|
||||
/* ── Runtime symbols required by the soul modules ──────────────────────────── */
|
||||
/* All implemented in el_runtime.c but omitted from this release header; the soul dist modules
|
||||
* reference them directly, so the public header must export them. Declarations only — mirrors the
|
||||
* mainline el_runtime.h and is platform-independent (no behavioural change to the POSIX build). */
|
||||
typedef el_val_t (*http_handler_fn)(el_val_t method, el_val_t path, el_val_t body);
|
||||
typedef el_val_t (*http_handler4_fn)(el_val_t method, el_val_t path, el_val_t body, el_val_t headers);
|
||||
el_val_t el_arena_push(void);
|
||||
el_val_t el_arena_pop(el_val_t mark);
|
||||
void http_serve_async(el_val_t port, el_val_t handler);
|
||||
el_val_t engram_get_node_by_label(el_val_t label);
|
||||
el_val_t engram_prune_telemetry(el_val_t older_than_ms);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Build + runtime artifacts — never committed.
|
||||
bin/
|
||||
# Captured media (camera frames, mic audio) and syntheses. Raw streams stay
|
||||
# LOCAL and never egress — including into git.
|
||||
out/
|
||||
# Runtime consent + resume state (local, per-machine).
|
||||
.consent.json
|
||||
.resume.json
|
||||
@@ -0,0 +1,80 @@
|
||||
# peripheral — Neuron's I/O organ (own-core, local, consent-gated)
|
||||
|
||||
The interface made physical. Two afferent senses in, one efferent voice out —
|
||||
all reached the way the agentic surface reaches any tool.
|
||||
|
||||
```
|
||||
MIC (hear) afferent device -> capture -> descriptor -> ingest -> geometry
|
||||
CAMERA (see) afferent device -> capture -> descriptor -> ingest -> scene-geometry
|
||||
SPEAKER(speak) efferent render WAV -> PLAY ALOUD out the speaker
|
||||
```
|
||||
|
||||
Closes the conversational loop: **hear (mic) -> understand (engram) -> speak (speaker)**.
|
||||
|
||||
## Rails
|
||||
- **Own-core.** macOS-native only: AVFoundation (camera/mic), CoreAudio voice-
|
||||
processing (AEC), afplay (speaker), ImageIO/CoreGraphics (frames), hand-rolled
|
||||
DSP (WAV, LPC, formant synthesis). No cloud, no heavy deps.
|
||||
- **Local-only.** Raw streams are written to `out/` and never egress. `.gitignore`
|
||||
keeps captured media out of git.
|
||||
- **Consent-gated (two locks).** A Neuron-level grant (`grant`/`revoke`) *and* the
|
||||
OS TCC permission. Sensitive senses (camera/mic) fail closed without both.
|
||||
- **Disclosed.** Every device touch prints a `[peripheral]` line on stderr.
|
||||
|
||||
## Build
|
||||
```
|
||||
swiftc -O -o bin/periph src/periph.swift \
|
||||
-framework AVFoundation -framework CoreMedia -framework Foundation \
|
||||
-framework CoreGraphics -framework ImageIO -framework CoreImage
|
||||
```
|
||||
|
||||
## Commands
|
||||
```
|
||||
periph grant|revoke <camera|mic> # Neuron-level consent
|
||||
periph status
|
||||
periph speak <file.wav> # SPEAK ALOUD (efferent)
|
||||
periph tone <out.wav> [hz] [sec] # own-core WAV synth
|
||||
periph listen <sec> <out.wav> # MIC capture (afferent), 16k mono
|
||||
periph see <out.jpg> # CAMERA one frame (afferent)
|
||||
periph feat-audio <wav> | feat-image <jpg> # capture -> compact descriptor
|
||||
periph ingest-audio|ingest-image <file> <engramURL> # descriptor -> engram node (geometry)
|
||||
periph voiceprint <voice.wav> # extract F0 + formants F1-F5
|
||||
periph imitate <voice.wav> <out.wav> # speak back in that voice (LPC resynthesis)
|
||||
periph hear-imitate <sec> <out.wav> # MIC -> signature -> imitate -> SPEAK ALOUD
|
||||
periph converse <manifest.json> [--authority F] [--barge-at S[:backchannel|:bargein]] [--resume] [--live-mic]
|
||||
```
|
||||
|
||||
## The afferent metabolism
|
||||
A capture is never shipped raw. It becomes a **compact descriptor** — the afferent
|
||||
twin of the music instrument-signature:
|
||||
- audio -> `[seconds, sr, ch, rms, peak, zcr, centroid, F0]` (~2400-6000x smaller)
|
||||
- image -> `[w, h, meanRGB, brightness, 3x3 luminance grid]` (~400000x smaller)
|
||||
- voice -> `[F0, F1..F5, bandwidths]` (11 numbers)
|
||||
|
||||
That descriptor is what the ingest organ (engram `POST /api/nodes`) turns into an
|
||||
embedded node = geometry.
|
||||
|
||||
## Voice by imitation
|
||||
`voiceprint`/`imitate` are own-core LPC (autocorrelation + Levinson-Durbin, order
|
||||
16 @ 16 kHz), formant extraction from the LPC spectral envelope, and source-filter
|
||||
resynthesis (glottal impulse train at F0 through the all-pole formant filter). A
|
||||
voice is grabbed by ear as ~a dozen numbers and spoken back — **no training, no
|
||||
stolen voice.** Measured fidelity on real speech: resynthesized formants match the
|
||||
source within 2-3%. The full phoneme->formant path for *novel* sentences is the
|
||||
speech faculty's seam (`elp` audio surface profile); this engine provides the
|
||||
formant synthesis primitive it renders through.
|
||||
|
||||
## Interruptibility (native turn-taking)
|
||||
`converse` plays the utterance as an ordered, salience-tagged **meaning-plan**
|
||||
while the mic listens (full-duplex, AEC on so it never barges in on its own voice):
|
||||
- **barge-in**: user speech -> pause on the spot (sample-accurate), not "finish the buffer."
|
||||
- **yield-or-hold**: a decision grounded in the current segment's salience + progress
|
||||
+ the interrupter's authority — YIELD (stop) or HOLD ("hang on, let me finish").
|
||||
- **backchannel** ("mm-hm"): brief/low -> keep going, resume seamlessly.
|
||||
- **resumable**: on yield the remaining plan persists (`.resume.json`); `--resume`
|
||||
picks the thread back up ("as I was saying").
|
||||
|
||||
Live full-duplex uses `--live-mic` (OS AEC). Injected `--barge-at` drives the
|
||||
decision loop deterministically for testing.
|
||||
```
|
||||
```
|
||||
@@ -0,0 +1,939 @@
|
||||
// periph.swift — Neuron's PERIPHERAL I/O organ (own-core, LOCAL, CONSENT-GATED).
|
||||
//
|
||||
// The interface made physical:
|
||||
// MIC (hear) = afferent : device -> capture -> [ingest -> geometry]
|
||||
// CAMERA (see) = afferent : device -> capture -> [ingest -> scene-geometry]
|
||||
// SPEAKER(speak) = efferent : [render WAV] -> PLAY ALOUD out the speaker
|
||||
//
|
||||
// Rails: own-core (AVFoundation / CoreAudio / afplay — all ship with macOS),
|
||||
// no cloud, no heavy deps, raw streams stay LOCAL and never egress,
|
||||
// every device access is CONSENT-GATED and DISCLOSED.
|
||||
//
|
||||
// Full-duplex CONVERSE mode implements native interruptibility: while the
|
||||
// speaker plays the utterance (a persistent, segmented meaning-plan), the mic
|
||||
// listens; on user speech it interrupts instantly, then DECIDES yield-or-hold
|
||||
// grounded in the salience of what it is mid-saying, and can RESUME the thread.
|
||||
//
|
||||
// Build: swiftc -O -o peripheral/bin/periph peripheral/src/periph.swift \
|
||||
// -framework AVFoundation -framework CoreMedia -framework Foundation
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import CoreMedia
|
||||
import CoreGraphics
|
||||
import ImageIO
|
||||
import CoreImage
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Disclosure — every peripheral touch is announced on stderr. Nothing is silent.
|
||||
// ----------------------------------------------------------------------------
|
||||
func disclose(_ msg: String) {
|
||||
FileHandle.standardError.write(" [peripheral] \(msg)\n".data(using: .utf8)!)
|
||||
}
|
||||
func emit(_ obj: [String: Any]) { // machine-readable event on stdout (JSON line)
|
||||
if let d = try? JSONSerialization.data(withJSONObject: obj),
|
||||
let s = String(data: d, encoding: .utf8) {
|
||||
print(s)
|
||||
}
|
||||
}
|
||||
func die(_ msg: String) -> Never {
|
||||
disclose("ERROR: \(msg)")
|
||||
emit(["ok": false, "error": msg])
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Consent store — Neuron's OWN gate, on top of the OS (TCC) gate. Two locks on
|
||||
// the sensitive senses. Persisted locally next to the binary's organ dir.
|
||||
// ----------------------------------------------------------------------------
|
||||
struct Consent {
|
||||
static let path: String = {
|
||||
let dir = ProcessInfo.processInfo.environment["PERIPH_HOME"]
|
||||
?? FileManager.default.currentDirectoryPath + "/peripheral"
|
||||
return dir + "/.consent.json"
|
||||
}()
|
||||
|
||||
static func load() -> [String: Bool] {
|
||||
guard let d = FileManager.default.contents(atPath: path),
|
||||
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Bool]
|
||||
else { return ["camera": false, "mic": false] }
|
||||
return o
|
||||
}
|
||||
static func save(_ g: [String: Bool]) {
|
||||
let d = try! JSONSerialization.data(withJSONObject: g, options: [.prettyPrinted])
|
||||
try? d.write(to: URL(fileURLWithPath: path))
|
||||
}
|
||||
// Neuron-level gate. Sensitive senses (camera/mic) require an explicit grant.
|
||||
static func require(_ device: String) {
|
||||
let g = load()
|
||||
if g[device] != true {
|
||||
die("CONSENT DENIED for '\(device)'. The user has not granted this sense. " +
|
||||
"Run: periph grant \(device) (raw streams stay local, never egress).")
|
||||
}
|
||||
disclose("consent OK (Neuron-level) for '\(device)' — local only, never egresses.")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// OS (TCC) permission — the second lock. AVFoundation prompts the user the first
|
||||
// time; if denied, we fail cleanly rather than hang.
|
||||
// ----------------------------------------------------------------------------
|
||||
func requireOSAccess(_ media: AVMediaType, _ label: String) {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: media)
|
||||
switch status {
|
||||
case .authorized:
|
||||
disclose("consent OK (OS/TCC) for \(label).")
|
||||
return
|
||||
case .notDetermined:
|
||||
disclose("requesting OS permission for \(label) (first use) — user must grant...")
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var ok = false
|
||||
AVCaptureDevice.requestAccess(for: media) { granted in ok = granted; sem.signal() }
|
||||
_ = sem.wait(timeout: .now() + 30)
|
||||
if !ok { die("OS permission for \(label) was not granted.") }
|
||||
disclose("consent OK (OS/TCC) for \(label).")
|
||||
case .denied, .restricted:
|
||||
die("OS permission for \(label) is DENIED in System Settings > Privacy. " +
|
||||
"Grant it to the controlling terminal/app, then retry.")
|
||||
@unknown default:
|
||||
die("unknown OS permission state for \(label).")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Own-core WAV writer (16-bit PCM). No library — proves we own the medium.
|
||||
// ----------------------------------------------------------------------------
|
||||
func writeWav(_ url: URL, samples: [Int16], sampleRate: Int, channels: Int = 1) {
|
||||
var data = Data()
|
||||
func u32(_ v: UInt32) { var x = v.littleEndian; data.append(Data(bytes: &x, count: 4)) }
|
||||
func u16(_ v: UInt16) { var x = v.littleEndian; data.append(Data(bytes: &x, count: 2)) }
|
||||
let bytesPerSample = 2
|
||||
let dataBytes = samples.count * bytesPerSample
|
||||
let byteRate = sampleRate * channels * bytesPerSample
|
||||
data.append("RIFF".data(using: .ascii)!); u32(UInt32(36 + dataBytes))
|
||||
data.append("WAVE".data(using: .ascii)!)
|
||||
data.append("fmt ".data(using: .ascii)!); u32(16); u16(1); u16(UInt16(channels))
|
||||
u32(UInt32(sampleRate)); u32(UInt32(byteRate))
|
||||
u16(UInt16(channels * bytesPerSample)); u16(16)
|
||||
data.append("data".data(using: .ascii)!); u32(UInt32(dataBytes))
|
||||
for s in samples { var x = s.littleEndian; data.append(Data(bytes: &x, count: 2)) }
|
||||
try? data.write(to: url)
|
||||
}
|
||||
|
||||
// Read a WAV's basic geometry (own-core header parse). Walks chunks to find
|
||||
// 'fmt ' and 'data' — robust to JUNK/FLLR padding chunks (AVAudioRecorder emits them).
|
||||
func wavInfo(_ path: String) -> (sampleRate: Int, channels: Int, bits: Int, frames: Int)? {
|
||||
guard let d = FileManager.default.contents(atPath: path), d.count > 44 else { return nil }
|
||||
func rd16(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1]) << 8) }
|
||||
func rd32(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1])<<8) | (Int(d[o+2])<<16) | (Int(d[o+3])<<24) }
|
||||
var channels = 0, sampleRate = 0, bits = 0, dataSize = 0
|
||||
var o = 12
|
||||
while o + 8 <= d.count {
|
||||
let id = String(bytes: d[o..<o+4], encoding: .ascii) ?? ""
|
||||
let sz = rd32(o+4)
|
||||
if id == "fmt " && o + 24 <= d.count {
|
||||
channels = rd16(o+10); sampleRate = rd32(o+12); bits = rd16(o+22)
|
||||
} else if id == "data" {
|
||||
dataSize = min(sz, d.count - (o+8))
|
||||
}
|
||||
o += 8 + sz + (sz & 1)
|
||||
}
|
||||
let frames = (channels > 0 && bits > 0) ? dataSize / (channels * bits/8) : 0
|
||||
return (sampleRate, channels, bits, frames)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// SPEAKER (efferent) — play a WAV ALOUD. Own-core: afplay ships with macOS.
|
||||
// ----------------------------------------------------------------------------
|
||||
func speak(_ wavPath: String) {
|
||||
guard FileManager.default.fileExists(atPath: wavPath) else { die("no such file: \(wavPath)") }
|
||||
disclose("SPEAKER: playing '\(wavPath)' ALOUD out the local speaker (efferent).")
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: "/usr/bin/afplay")
|
||||
p.arguments = [wavPath]
|
||||
try? p.run(); p.waitUntilExit()
|
||||
let ok = p.terminationStatus == 0
|
||||
disclose(ok ? "SPEAKER: done — Neuron spoke aloud." : "SPEAKER: afplay failed.")
|
||||
if let i = wavInfo(wavPath) {
|
||||
emit(["ok": ok, "op": "speak", "file": wavPath, "played_aloud": ok,
|
||||
"sample_rate": i.sampleRate, "channels": i.channels,
|
||||
"seconds": Double(i.frames)/Double(max(i.sampleRate,1))])
|
||||
} else {
|
||||
emit(["ok": ok, "op": "speak", "file": wavPath, "played_aloud": ok])
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// MIC (afferent) — capture N seconds -> 16k mono 16-bit WAV (formant-ready).
|
||||
// ----------------------------------------------------------------------------
|
||||
func listen(seconds: Double, out: String) {
|
||||
Consent.require("mic")
|
||||
requireOSAccess(.audio, "microphone")
|
||||
disclose("MIC: capturing \(seconds)s -> '\(out)' (16 kHz mono, LOCAL, never egresses).")
|
||||
|
||||
let url = URL(fileURLWithPath: out)
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatLinearPCM,
|
||||
AVSampleRateKey: 16000.0,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVLinearPCMBitDepthKey: 16,
|
||||
AVLinearPCMIsFloatKey: false,
|
||||
AVLinearPCMIsBigEndianKey: false,
|
||||
]
|
||||
guard let rec = try? AVAudioRecorder(url: url, settings: settings) else {
|
||||
die("could not open the microphone recorder.")
|
||||
}
|
||||
rec.record()
|
||||
Thread.sleep(forTimeInterval: seconds)
|
||||
rec.stop()
|
||||
// let the file flush
|
||||
Thread.sleep(forTimeInterval: 0.1)
|
||||
if let i = wavInfo(out) {
|
||||
disclose("MIC: captured \(i.frames) frames @ \(i.sampleRate)Hz — ready to hand to the ingest organ.")
|
||||
emit(["ok": true, "op": "listen", "file": out, "sample_rate": i.sampleRate,
|
||||
"channels": i.channels, "frames": i.frames,
|
||||
"seconds": Double(i.frames)/Double(max(i.sampleRate,1)),
|
||||
"next": "ingest -> phonetic/voice geometry"])
|
||||
} else {
|
||||
die("mic capture produced no readable WAV.")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// CAMERA (afferent) — capture ONE frame -> JPEG on disk.
|
||||
// ----------------------------------------------------------------------------
|
||||
// Grab one video frame via AVCaptureVideoDataOutput (CLI-safe; no KVO/photo classes).
|
||||
final class FrameGrabber: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate {
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
var cgImage: CGImage?
|
||||
var seen = 0
|
||||
let cictx = CIContext(options: nil)
|
||||
func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer,
|
||||
from connection: AVCaptureConnection) {
|
||||
seen += 1
|
||||
if cgImage != nil || seen < 5 { return } // let exposure settle a few frames
|
||||
guard let pb = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
|
||||
let ci = CIImage(cvPixelBuffer: pb)
|
||||
cgImage = cictx.createCGImage(ci, from: ci.extent)
|
||||
sem.signal()
|
||||
}
|
||||
}
|
||||
func see(out: String) {
|
||||
Consent.require("camera")
|
||||
requireOSAccess(.video, "camera")
|
||||
disclose("CAMERA: capturing one frame -> '\(out)' (LOCAL, never egresses).")
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { die("no camera device available.") }
|
||||
session.addInput(input)
|
||||
let output = AVCaptureVideoDataOutput()
|
||||
output.alwaysDiscardsLateVideoFrames = true
|
||||
let grabber = FrameGrabber()
|
||||
output.setSampleBufferDelegate(grabber, queue: DispatchQueue(label: "periph.cam"))
|
||||
guard session.canAddOutput(output) else { die("cannot add video output.") }
|
||||
session.addOutput(output)
|
||||
session.startRunning()
|
||||
|
||||
if grabber.sem.wait(timeout: .now() + 10) == .timedOut { session.stopRunning(); die("camera capture timed out.") }
|
||||
session.stopRunning()
|
||||
|
||||
guard let cg = grabber.cgImage,
|
||||
let dst = CGImageDestinationCreateWithURL(URL(fileURLWithPath: out) as CFURL,
|
||||
"public.jpeg" as CFString, 1, nil)
|
||||
else { die("camera returned no frame.") }
|
||||
CGImageDestinationAddImage(dst, cg, nil)
|
||||
guard CGImageDestinationFinalize(dst) else { die("could not write JPEG.") }
|
||||
let bytes = ((try? FileManager.default.attributesOfItem(atPath: out))?[.size] as? Int) ?? 0
|
||||
disclose("CAMERA: wrote \(cg.width)x\(cg.height) frame (\(bytes) bytes) — ready for scene-geometry ingest.")
|
||||
emit(["ok": true, "op": "see", "file": out, "width": cg.width, "height": cg.height,
|
||||
"bytes": bytes, "next": "ingest -> scene-geometry"])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FEAT — the afferent METABOLISM: a raw capture becomes a COMPACT descriptor
|
||||
// (a few dozen numbers), the mirror of the efferent signature. This is what
|
||||
// gets handed to the ingest organ as geometry — NOT the raw stream. Own-core.
|
||||
// ============================================================================
|
||||
|
||||
// Read all 16-bit PCM samples from a WAV (own-core).
|
||||
func readWavSamples(_ path: String) -> (samples: [Double], sr: Int, ch: Int)? {
|
||||
guard let d = FileManager.default.contents(atPath: path), d.count > 44 else { return nil }
|
||||
func rd16(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1]) << 8) }
|
||||
func rd32(_ o: Int) -> Int { Int(d[o]) | (Int(d[o+1])<<8) | (Int(d[o+2])<<16) | (Int(d[o+3])<<24) }
|
||||
var ch = 0, sr = 0, bits = 0
|
||||
var o = 12
|
||||
while o + 8 <= d.count {
|
||||
let id = String(bytes: d[o..<o+4], encoding: .ascii) ?? ""
|
||||
let sz = rd32(o+4)
|
||||
if id == "fmt " && o + 24 <= d.count { ch = rd16(o+10); sr = rd32(o+12); bits = rd16(o+22) }
|
||||
if id == "data" {
|
||||
guard bits == 16, ch > 0 else { return nil }
|
||||
var samples = [Double](); let start = o + 8
|
||||
let end = min(start + sz, d.count - 1)
|
||||
var i = start
|
||||
while i + 1 < end {
|
||||
var v = Int(rd16(i)); if v >= 32768 { v -= 65536 }
|
||||
samples.append(Double(v) / 32768.0)
|
||||
i += 2 * ch // take channel 0 if stereo
|
||||
}
|
||||
return (samples, sr, ch)
|
||||
}
|
||||
o += 8 + sz + (sz & 1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Audio descriptor = compact sound/voice signature (energy, ZCR, centroid, F0).
|
||||
// The seed for phonetic geometry + the hear->imitate voice-signature.
|
||||
func computeAudio(_ path: String) -> (content: String, vector: [Double], extra: [String: Any]) {
|
||||
guard let (s, sr, ch) = readWavSamples(path), !s.isEmpty else { die("cannot read PCM from \(path)") }
|
||||
let n = s.count
|
||||
let seconds = Double(n) / Double(sr)
|
||||
var sumsq = 0.0, peak = 0.0, zc = 0.0
|
||||
for i in 0..<n {
|
||||
sumsq += s[i]*s[i]; peak = max(peak, abs(s[i]))
|
||||
if i > 0 && (s[i-1] < 0) != (s[i] < 0) { zc += 1 }
|
||||
}
|
||||
let rms = (sumsq / Double(n)).squareRoot()
|
||||
let zcr = zc / Double(n) * Double(sr) // ~2*dominant freq for tonal
|
||||
// Spectral centroid via a coarse DFT on a mid window (own-core).
|
||||
let W = min(2048, n); let off = max(0, (n - W)/2)
|
||||
var num = 0.0, den = 0.0
|
||||
let bins = 64
|
||||
for k in 1..<bins {
|
||||
let f = Double(k) * Double(sr) / Double(2*bins)
|
||||
var re = 0.0, im = 0.0
|
||||
for j in 0..<W {
|
||||
let ang = -2*Double.pi*Double(k)*Double(j)/Double(2*bins)
|
||||
re += s[off+j]*cos(ang); im += s[off+j]*sin(ang)
|
||||
}
|
||||
let mag = (re*re+im*im).squareRoot()
|
||||
num += f*mag; den += mag
|
||||
}
|
||||
let centroid = den > 0 ? num/den : 0
|
||||
// F0 via autocorrelation (voice pitch) over plausible speech range 70-400 Hz.
|
||||
var bestLag = 0; var bestCorr = 0.0
|
||||
let lagMin = sr/400, lagMax = min(sr/70, n-1)
|
||||
if lagMax > lagMin {
|
||||
for lag in lagMin...lagMax {
|
||||
var c = 0.0
|
||||
var i = 0; while i + lag < min(n, off+W) { c += s[off+i]*s[off+i+lag]; i += 1 }
|
||||
if c > bestCorr { bestCorr = c; bestLag = lag }
|
||||
}
|
||||
}
|
||||
let f0 = bestLag > 0 ? Double(sr)/Double(bestLag) : 0
|
||||
let vector: [Double] = [seconds, Double(sr), Double(ch), rms, peak, zcr, centroid, f0]
|
||||
let content = String(format:
|
||||
"Heard sound (afferent, mic): %.2fs at %dHz. RMS energy %.3f, peak %.3f, " +
|
||||
"zero-crossing rate %.0fHz, spectral centroid %.0fHz, estimated voice pitch F0 %.0fHz. " +
|
||||
"Compact voice/sound signature (%d numbers) — phonetic geometry + hear-to-imitate seed.",
|
||||
seconds, sr, rms, peak, zcr, centroid, f0, vector.count)
|
||||
disclose("FEAT(audio): \(vector.count)-number signature vs \(n) raw samples (~\(n/max(vector.count,1))x compression).")
|
||||
return (content, vector, ["f0_hz": f0, "centroid_hz": centroid, "zcr_hz": zcr,
|
||||
"rms": rms, "seconds": seconds, "raw_samples": n])
|
||||
}
|
||||
func featAudio(_ path: String) {
|
||||
let r = computeAudio(path)
|
||||
var out: [String: Any] = ["ok": true, "op": "feat-audio", "file": path,
|
||||
"vector": r.vector, "content": r.content,
|
||||
"ingest": ["node_type": "Observation", "tier": "Episodic", "content": r.content]]
|
||||
r.extra.forEach { out[$0] = $1 }
|
||||
emit(out)
|
||||
}
|
||||
|
||||
// Image descriptor = compact scene-geometry (dims, brightness, region grid).
|
||||
func computeImage(_ path: String) -> (content: String, vector: [Double], extra: [String: Any]) {
|
||||
guard let src = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil),
|
||||
let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else { die("cannot decode image \(path)") }
|
||||
let w = img.width, h = img.height
|
||||
let cs = CGColorSpaceCreateDeviceRGB()
|
||||
let bpr = w * 4
|
||||
var buf = [UInt8](repeating: 0, count: h * bpr)
|
||||
guard let ctx = CGContext(data: &buf, width: w, height: h, bitsPerComponent: 8,
|
||||
bytesPerRow: bpr, space: cs,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else {
|
||||
die("cannot rasterize image")
|
||||
}
|
||||
ctx.draw(img, in: CGRect(x: 0, y: 0, width: w, height: h))
|
||||
// 3x3 region average luminance + overall average color.
|
||||
var rAvg = 0.0, gAvg = 0.0, bAvg = 0.0
|
||||
var grid = [Double](repeating: 0, count: 9); var gridN = [Int](repeating: 0, count: 9)
|
||||
let step = max(1, (w*h)/40000) // subsample for speed
|
||||
var count = 0; var idx = 0
|
||||
while idx < w*h {
|
||||
let x = idx % w, y = idx / w
|
||||
let p = y*bpr + x*4
|
||||
let r = Double(buf[p]), g = Double(buf[p+1]), b = Double(buf[p+2])
|
||||
rAvg += r; gAvg += g; bAvg += b; count += 1
|
||||
let cell = (min(2, y*3/h))*3 + min(2, x*3/w)
|
||||
grid[cell] += 0.299*r + 0.587*g + 0.114*b; gridN[cell] += 1
|
||||
idx += step
|
||||
}
|
||||
if count == 0 { die("no pixels sampled") }
|
||||
rAvg /= Double(count); gAvg /= Double(count); bAvg /= Double(count)
|
||||
for i in 0..<9 { grid[i] = gridN[i] > 0 ? grid[i]/Double(gridN[i]) : 0 }
|
||||
let bright = (0.299*rAvg + 0.587*gAvg + 0.114*bAvg)/255.0
|
||||
let vector = [Double(w), Double(h), rAvg/255, gAvg/255, bAvg/255, bright] + grid.map { $0/255 }
|
||||
let content = String(format:
|
||||
"Saw scene (afferent, camera): %dx%d frame. Mean color rgb(%.0f,%.0f,%.0f), " +
|
||||
"brightness %.2f. 3x3 luminance grid [%.0f %.0f %.0f / %.0f %.0f %.0f / %.0f %.0f %.0f]. " +
|
||||
"Compact scene-geometry (%d numbers) vs %d pixel-channels.",
|
||||
w, h, rAvg, gAvg, bAvg, bright,
|
||||
grid[0],grid[1],grid[2],grid[3],grid[4],grid[5],grid[6],grid[7],grid[8],
|
||||
vector.count, w*h*3)
|
||||
disclose("FEAT(image): \(vector.count)-number scene-geometry vs \(w*h*3) pixel-channels (~\(w*h*3/max(vector.count,1))x).")
|
||||
return (content, vector, ["width": w, "height": h, "brightness": bright])
|
||||
}
|
||||
func featImage(_ path: String) {
|
||||
let r = computeImage(path)
|
||||
var out: [String: Any] = ["ok": true, "op": "feat-image", "file": path,
|
||||
"vector": r.vector, "content": r.content,
|
||||
"ingest": ["node_type": "Observation", "tier": "Episodic", "content": r.content]]
|
||||
r.extra.forEach { out[$0] = $1 }
|
||||
emit(out)
|
||||
}
|
||||
|
||||
// The afferent WIRE — hand a capture's descriptor to the ingest organ (engram),
|
||||
// where it becomes an embedded node = GEOMETRY. Own-core URLSession POST.
|
||||
// LOCAL only: point at a local engram; raw stream never leaves the machine.
|
||||
func postNode(engramURL: String, content: String, label: String, tags: [String]) -> String? {
|
||||
guard let url = URL(string: engramURL + "/api/nodes") else { return nil }
|
||||
let body: [String: Any] = ["content": content, "node_type": "Observation",
|
||||
"label": label, "tier": "Episodic",
|
||||
"salience": 0.7, "importance": 0.6, "confidence": 0.9,
|
||||
"tags": tags]
|
||||
var req = URLRequest(url: url); req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
let sem = DispatchSemaphore(value: 0); var out: String?
|
||||
URLSession.shared.dataTask(with: req) { data, _, _ in
|
||||
if let d = data { out = String(data: d, encoding: .utf8) }
|
||||
sem.signal()
|
||||
}.resume()
|
||||
_ = sem.wait(timeout: .now() + 15)
|
||||
return out
|
||||
}
|
||||
func ingest(_ path: String, kind: String, engramURL: String) {
|
||||
let r = kind == "audio" ? computeAudio(path) : computeImage(path)
|
||||
let label = kind == "audio" ? "heard:mic" : "saw:camera"
|
||||
disclose("INGEST: handing \(kind) descriptor to the ingest organ at \(engramURL) (LOCAL) -> geometry.")
|
||||
guard let resp = postNode(engramURL: engramURL, content: r.content, label: label,
|
||||
tags: ["peripheral", kind == "audio" ? "afferent-mic" : "afferent-camera"]) else {
|
||||
die("ingest POST failed (no local engram at \(engramURL)?)")
|
||||
}
|
||||
// pull the node id out of the response (own-core, tolerant)
|
||||
var nodeId = ""
|
||||
if let d = resp.data(using: .utf8),
|
||||
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
|
||||
nodeId = (o["id"] as? String) ?? (o["node_id"] as? String) ?? ""
|
||||
}
|
||||
disclose("INGEST: landed as node \(nodeId.isEmpty ? "(see response)" : nodeId) — the capture is now geometry in the engram.")
|
||||
emit(["ok": !nodeId.isEmpty, "op": "ingest-\(kind)", "file": path,
|
||||
"node_id": nodeId, "engram_response": resp, "content": r.content,
|
||||
"vector": r.vector])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VOICE BY IMITATION — hear a voice, grab its compact SIGNATURE (pitch +
|
||||
// formants F1-F5 via LPC), and speak back in that voice by source-filter
|
||||
// resynthesis. Own-core DSP (physics), no training, no stolen voice. The
|
||||
// afferent twin of the music instrument-signature: a voice = a few dozen
|
||||
// numbers, not a corpus.
|
||||
// ============================================================================
|
||||
|
||||
func hamming(_ x: [Double]) -> [Double] {
|
||||
let n = x.count; if n < 2 { return x }
|
||||
return (0..<n).map { x[$0] * (0.54 - 0.46*cos(2*Double.pi*Double($0)/Double(n-1))) }
|
||||
}
|
||||
func autocorr(_ x: [Double], _ p: Int) -> [Double] {
|
||||
var r = [Double](repeating: 0, count: p+1)
|
||||
for lag in 0...p { var s = 0.0; var i = lag; while i < x.count { s += x[i]*x[i-lag]; i += 1 }; r[lag] = s }
|
||||
return r
|
||||
}
|
||||
// Levinson-Durbin -> LPC coeffs a[0..p] (A(z)=1+sum a[k]z^-k) and residual energy.
|
||||
func levinson(_ r: [Double], _ p: Int) -> (a: [Double], err: Double) {
|
||||
var a = [Double](repeating: 0, count: p+1); a[0] = 1
|
||||
var err = r[0]
|
||||
if err <= 0 { return (a, 0) }
|
||||
for i in 1...p {
|
||||
var acc = r[i]
|
||||
if i > 1 { for j in 1..<i { acc += a[j]*r[i-j] } }
|
||||
let k = -acc/err
|
||||
var na = a; na[i] = k
|
||||
if i > 1 { for j in 1..<i { na[j] = a[j] + k*a[i-j] } }
|
||||
a = na; err *= (1 - k*k)
|
||||
if err <= 0 { break }
|
||||
}
|
||||
return (a, err)
|
||||
}
|
||||
// Formant peaks from the LPC all-pole spectral envelope.
|
||||
func formants(_ a: [Double], sr: Int) -> [(f: Double, bw: Double)] {
|
||||
let p = a.count - 1
|
||||
let steps = 512
|
||||
var mag = [Double](repeating: 0, count: steps)
|
||||
for s in 0..<steps {
|
||||
let w = Double.pi * Double(s) / Double(steps) // 0..pi -> 0..sr/2
|
||||
var re = 0.0, im = 0.0
|
||||
for k in 0...p { re += a[k]*cos(w*Double(k)); im -= a[k]*sin(w*Double(k)) }
|
||||
mag[s] = 1.0 / max((re*re+im*im).squareRoot(), 1e-9)
|
||||
}
|
||||
var peaks: [(f: Double, bw: Double)] = []
|
||||
for s in 1..<(steps-1) where mag[s] > mag[s-1] && mag[s] >= mag[s+1] {
|
||||
let f = Double(s) * Double(sr) / 2 / Double(steps)
|
||||
if f > 150 && f < 5200 {
|
||||
// crude bandwidth: width where magnitude falls to peak/sqrt(2)
|
||||
let thr = mag[s]/1.4142
|
||||
var lo = s; while lo > 0 && mag[lo] > thr { lo -= 1 }
|
||||
var hi = s; while hi < steps-1 && mag[hi] > thr { hi += 1 }
|
||||
let bw = Double(hi-lo) * Double(sr) / 2 / Double(steps)
|
||||
peaks.append((f, bw))
|
||||
}
|
||||
}
|
||||
return Array(peaks.prefix(5))
|
||||
}
|
||||
func pitchOf(_ frame: [Double], sr: Int) -> Double {
|
||||
let n = frame.count
|
||||
let lagMin = sr/400, lagMax = min(sr/70, n-1)
|
||||
if lagMax <= lagMin { return 0 }
|
||||
var r0 = 0.0; for v in frame { r0 += v*v }
|
||||
if r0 < 1e-5 { return 0 }
|
||||
var bestLag = 0; var best = 0.0
|
||||
for lag in lagMin...lagMax { var c = 0.0; var i = lag; while i < n { c += frame[i]*frame[i-lag]; i += 1 }; if c > best { best = c; bestLag = lag } }
|
||||
return (best / r0 > 0.30 && bestLag > 0) ? Double(sr)/Double(bestLag) : 0 // voiced?
|
||||
}
|
||||
|
||||
let LPC_ORDER = 16
|
||||
let FRAME = 400 // 25ms @16k
|
||||
let HOP = 160 // 10ms
|
||||
|
||||
// Extract Will's voice-signature: averaged F0 + formants over voiced frames.
|
||||
func voiceprint(_ path: String) -> (f0: Double, f0lo: Double, f0hi: Double, formants: [(Double,Double)], content: String) {
|
||||
guard let (x, sr, _) = readWavSamples(path), x.count > FRAME else { die("cannot read speech from \(path)") }
|
||||
var f0s: [Double] = []
|
||||
var fbank: [[Double]] = [[],[],[],[],[]]
|
||||
var bbank: [[Double]] = [[],[],[],[],[]]
|
||||
var pos = 0
|
||||
while pos + FRAME <= x.count {
|
||||
let raw = Array(x[pos..<pos+FRAME])
|
||||
let f0 = pitchOf(raw, sr: sr)
|
||||
if f0 > 0 { // voiced frame only
|
||||
f0s.append(f0)
|
||||
let r = autocorr(hamming(raw), LPC_ORDER)
|
||||
if r[0] > 1e-6 {
|
||||
let (a, _) = levinson(r, LPC_ORDER)
|
||||
let fs = formants(a, sr: sr)
|
||||
for (i, fm) in fs.enumerated() where i < 5 { fbank[i].append(fm.f); bbank[i].append(fm.bw) }
|
||||
}
|
||||
}
|
||||
pos += HOP
|
||||
}
|
||||
func med(_ v: [Double]) -> Double { v.isEmpty ? 0 : v.sorted()[v.count/2] }
|
||||
let f0med = med(f0s)
|
||||
let f0lo = f0s.isEmpty ? 0 : f0s.sorted().first!
|
||||
let f0hi = f0s.isEmpty ? 0 : f0s.sorted().last!
|
||||
var forms: [(Double,Double)] = []
|
||||
for i in 0..<5 where !fbank[i].isEmpty { forms.append((med(fbank[i]), med(bbank[i]))) }
|
||||
let fstr = forms.map { String(format:"%.0f", $0.0) }.joined(separator: "/")
|
||||
let content = String(format:
|
||||
"Voice-signature (afferent, heard a voice): pitch F0 %.0fHz (range %.0f-%.0fHz), " +
|
||||
"formants F1-F5 = %@ Hz. Compact voiceprint (%d numbers) — grabbed by ear for imitation, not trained.",
|
||||
f0med, f0lo, f0hi, fstr, 1 + forms.count*2)
|
||||
return (f0med, f0lo, f0hi, forms, content)
|
||||
}
|
||||
|
||||
// IMITATE: LPC analysis-resynthesis. Reconstruct the heard voice from its
|
||||
// per-frame filter model + pitch — the voice rebuilt from its signature.
|
||||
func imitate(inPath: String, outPath: String) {
|
||||
guard let (x, sr, _) = readWavSamples(inPath), x.count > FRAME else { die("cannot read speech from \(inPath)") }
|
||||
var out = [Double](repeating: 0, count: x.count)
|
||||
var state = [Double](repeating: 0, count: LPC_ORDER) // past outputs
|
||||
var phase = 0.0
|
||||
var lastF0 = 0.0
|
||||
var pos = 0
|
||||
while pos + FRAME <= x.count {
|
||||
let raw = Array(x[pos..<pos+FRAME])
|
||||
let r = autocorr(hamming(raw), LPC_ORDER)
|
||||
let f0 = pitchOf(raw, sr: sr)
|
||||
if r[0] < 1e-7 { pos += HOP; continue }
|
||||
let (a, err) = levinson(r, LPC_ORDER)
|
||||
let gain = max(err, 0).squareRoot()
|
||||
let useF0 = f0 > 0 ? f0 : (lastF0 > 0 ? lastF0 : 0)
|
||||
lastF0 = f0
|
||||
for i in 0..<HOP {
|
||||
let idx = pos + i; if idx >= x.count { break }
|
||||
var e = 0.0
|
||||
if useF0 > 0 { // voiced: glottal impulse train
|
||||
phase += useF0/Double(sr)
|
||||
if phase >= 1.0 { phase -= 1.0; e = sqrt(Double(sr)/useF0) } // energy-normalized impulse
|
||||
} else { // unvoiced: noise
|
||||
e = Double.random(in: -1...1)
|
||||
}
|
||||
var y = gain * e
|
||||
for k in 1...LPC_ORDER { y -= a[k]*state[k-1] }
|
||||
for k in stride(from: LPC_ORDER-1, through: 1, by: -1) { state[k] = state[k-1] }
|
||||
state[0] = y
|
||||
out[idx] = y
|
||||
}
|
||||
pos += HOP
|
||||
}
|
||||
// normalize to peak 0.9
|
||||
let peak = out.map { abs($0) }.max() ?? 1
|
||||
let scale = peak > 1e-9 ? 0.9/peak : 1
|
||||
let samples = out.map { Int16(max(-32767, min(32767, $0*scale*32767))) }
|
||||
writeWav(URL(fileURLWithPath: outPath), samples: samples, sampleRate: sr)
|
||||
let vp = voiceprint(inPath)
|
||||
disclose(String(format: "IMITATE: rebuilt the voice from its signature (F0 %.0fHz, formants %@) -> %@",
|
||||
vp.f0, vp.formants.map{String(format:"%.0f",$0.0)}.joined(separator:"/"), outPath))
|
||||
emit(["ok": true, "op": "imitate", "in": inPath, "out": outPath,
|
||||
"f0_hz": vp.f0, "f0_range": [vp.f0lo, vp.f0hi],
|
||||
"formants_hz": vp.formants.map { $0.0 },
|
||||
"method": "LPC analysis-resynthesis (own-core, no training, no stolen voice)"])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONVERSE (full-duplex) — the interruptible conversational loop.
|
||||
// The utterance is a persistent, ordered meaning-plan of SEGMENTS, each with
|
||||
// a salience. The speaker plays them; the mic listens concurrently. On user
|
||||
// speech: pause INSTANTLY, classify (backchannel vs barge-in), then DECIDE
|
||||
// yield-or-hold from the salience of the current segment + the social read.
|
||||
// Yielded utterances persist their remaining plan so Neuron can RESUME.
|
||||
// ============================================================================
|
||||
struct Segment { let file: String; let salience: Double; let text: String }
|
||||
|
||||
enum Decision { case backchannelContinue, hold, yield }
|
||||
|
||||
// The yield-or-hold DECISION — grounded, contextual. Not a fixed rule.
|
||||
func decide(currentSalience: Double, progress: Double,
|
||||
interrupterAuthority: Double, isBackchannel: Bool) -> Decision {
|
||||
if isBackchannel { return .backchannelContinue } // "mm-hm" => keep going
|
||||
// Holding the floor is justified when what I'm saying matters AND I'm nearly
|
||||
// done (cheap to finish) AND the interrupter isn't high-priority.
|
||||
let holdScore = currentSalience * 0.6 + progress * 0.4
|
||||
if holdScore >= 0.6 && interrupterAuthority < 0.8 { return .hold }
|
||||
return .yield // default: be polite, let them in
|
||||
}
|
||||
|
||||
final class Conversation {
|
||||
let engine = AVAudioEngine()
|
||||
let player = AVAudioPlayerNode()
|
||||
var micLive = false
|
||||
// VAD state (shared with the audio tap thread)
|
||||
let lock = NSLock()
|
||||
var micRMS: Float = 0
|
||||
var speechFrames = 0 // consecutive above-threshold frames
|
||||
var onsetHandled = false
|
||||
|
||||
let resumePath: String
|
||||
init(resumePath: String) { self.resumePath = resumePath }
|
||||
|
||||
// Try to bring the mic up as a live VAD. Returns false if unavailable/denied.
|
||||
func startMic() -> Bool {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .audio)
|
||||
if Consent.load()["mic"] != true || status != .authorized {
|
||||
disclose("CONVERSE: live mic not available (consent/OS) — using injected barge events for the proof.")
|
||||
return false
|
||||
}
|
||||
let input = engine.inputNode
|
||||
// Acoustic echo cancellation: the OS voice-processing unit subtracts our
|
||||
// own speaker output from the mic so Neuron does NOT hear itself and
|
||||
// barge in on its own voice. This is what makes real-room barge-in work.
|
||||
do { try input.setVoiceProcessingEnabled(true); disclose("CONVERSE: AEC on (echo-cancelled mic — won't self-interrupt).") }
|
||||
catch { disclose("CONVERSE: AEC unavailable (\(error)); raising VAD floor instead.") }
|
||||
let fmt = input.inputFormat(forBus: 0)
|
||||
if fmt.sampleRate == 0 { return false }
|
||||
input.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in
|
||||
guard let self = self, let ch = buf.floatChannelData?[0] else { return }
|
||||
let n = Int(buf.frameLength)
|
||||
var sum: Float = 0
|
||||
for i in 0..<n { let v = ch[i]; sum += v*v }
|
||||
let rms = n > 0 ? (sum / Float(n)).squareRoot() : 0
|
||||
self.lock.lock(); self.micRMS = rms; self.lock.unlock()
|
||||
}
|
||||
micLive = true
|
||||
disclose("CONVERSE: full-duplex — mic listening WHILE speaking (barge-in armed).")
|
||||
return true
|
||||
}
|
||||
|
||||
func run(_ segs: [Segment], interrupterAuthority: Double,
|
||||
injectBargeAt: Double?, injectKind: String, startIndex: Int, liveMic: Bool) {
|
||||
engine.attach(player)
|
||||
let firstFmt = (try? AVAudioFile(forReading: URL(fileURLWithPath: segs[startIndex].file)))?.processingFormat
|
||||
?? AVAudioFormat(standardFormatWithSampleRate: 16000, channels: 1)!
|
||||
engine.connect(player, to: engine.mainMixerNode, format: firstFmt)
|
||||
if liveMic { _ = startMic() }
|
||||
else { disclose("CONVERSE: deterministic mode (live mic off) — barge events \(injectBargeAt != nil ? "injected" : "none").") }
|
||||
do { try engine.start() } catch { die("audio engine failed to start: \(error)") }
|
||||
player.play()
|
||||
|
||||
let injectDeadline = injectBargeAt.map { Date().addingTimeInterval($0) }
|
||||
var injectedFired = false
|
||||
var idx = startIndex
|
||||
|
||||
segmentLoop: while idx < segs.count {
|
||||
let seg = segs[idx]
|
||||
guard let f = try? AVAudioFile(forReading: URL(fileURLWithPath: seg.file)) else {
|
||||
disclose("CONVERSE: missing segment '\(seg.file)', skipping."); idx += 1; continue
|
||||
}
|
||||
let dur = Double(f.length) / f.processingFormat.sampleRate
|
||||
disclose(String(format: "CONVERSE: speaking segment %d/%d (salience %.2f) — \"%@\"",
|
||||
idx+1, segs.count, seg.salience, seg.text))
|
||||
emit(["op": "converse", "event": "speaking", "segment": idx,
|
||||
"salience": seg.salience, "text": seg.text])
|
||||
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
// .dataPlayedBack: completion fires only after the audio has actually
|
||||
// played OUT the DAC (not merely been consumed) — so the tail is never
|
||||
// clipped and playback always runs the FULL file length.
|
||||
player.scheduleFile(f, at: nil, completionCallbackType: .dataPlayedBack) { _ in done.signal() }
|
||||
player.play()
|
||||
|
||||
// Monitor this segment: poll VAD / injected event until it finishes.
|
||||
let segStart = Date()
|
||||
while done.wait(timeout: .now() + 0.02) == .timedOut {
|
||||
let elapsed = Date().timeIntervalSince(segStart)
|
||||
let progress = min(elapsed / max(dur, 0.001), 1.0)
|
||||
|
||||
// --- detect an onset (live mic OR injected) ---
|
||||
var onset = false
|
||||
if micLive {
|
||||
lock.lock(); let rms = micRMS; lock.unlock()
|
||||
if rms > 0.02 { speechFrames += 1 } else { speechFrames = 0 }
|
||||
if speechFrames >= 3 && !onsetHandled { onset = true } // ~60ms of voice
|
||||
}
|
||||
if let dl = injectDeadline, !injectedFired, Date() >= dl, !onsetHandled { onset = true; injectedFired = true }
|
||||
|
||||
if onset {
|
||||
onsetHandled = true
|
||||
// (1) BARGE-IN: pause INSTANTLY, on the spot.
|
||||
player.pause()
|
||||
let tBarge = Date().timeIntervalSince(segStart)
|
||||
disclose(String(format: "CONVERSE: << user speech at %.2fs into segment %d — PAUSED instantly >>", tBarge, idx+1))
|
||||
emit(["op": "converse", "event": "barge_in", "segment": idx,
|
||||
"at_seconds": tBarge, "progress": progress])
|
||||
|
||||
// (2) classify backchannel vs real barge-in
|
||||
let isBackchannel = classifyBackchannel(injected: injectDeadline != nil,
|
||||
kind: injectKind)
|
||||
let d = decide(currentSalience: seg.salience, progress: progress,
|
||||
interrupterAuthority: interrupterAuthority,
|
||||
isBackchannel: isBackchannel)
|
||||
switch d {
|
||||
case .backchannelContinue:
|
||||
disclose("CONVERSE: read as BACKCHANNEL (\"mm-hm\") — keep going, resume seamlessly.")
|
||||
emit(["op": "converse", "event": "backchannel_continue", "segment": idx])
|
||||
onsetHandled = false; speechFrames = 0
|
||||
player.play() // seamless resume
|
||||
case .hold:
|
||||
disclose("CONVERSE: HOLD the floor — \"hang on, let me finish this thought.\" (high salience, nearly done)")
|
||||
emit(["op": "converse", "event": "hold_floor", "segment": idx,
|
||||
"salience": seg.salience, "progress": progress])
|
||||
onsetHandled = false; speechFrames = 0
|
||||
player.play() // finish the segment, THEN yield
|
||||
// after this segment completes we yield the remainder
|
||||
_ = done.wait(timeout: .now() + dur + 1.0)
|
||||
persistResume(segs: segs, from: idx + 1, reason: "held-then-yield")
|
||||
finish(); return
|
||||
case .yield:
|
||||
disclose("CONVERSE: YIELD — stop, let them in. Remembering where I was (resumable).")
|
||||
player.stop()
|
||||
persistResume(segs: segs, from: idx, reason: "yield")
|
||||
emit(["op": "converse", "event": "yield", "interrupted_segment": idx,
|
||||
"resume_from": idx])
|
||||
finish(); return
|
||||
}
|
||||
}
|
||||
}
|
||||
emit(["op": "converse", "event": "segment_done", "segment": idx])
|
||||
idx += 1
|
||||
}
|
||||
// whole utterance completed uninterrupted
|
||||
clearResume()
|
||||
disclose("CONVERSE: utterance complete (uninterrupted).")
|
||||
emit(["ok": true, "op": "converse", "event": "complete", "segments": segs.count])
|
||||
finish()
|
||||
}
|
||||
|
||||
// A backchannel is brief/low. Injected kind lets us prove both paths headlessly;
|
||||
// the live path would measure post-onset duration & energy.
|
||||
func classifyBackchannel(injected: Bool, kind: String) -> Bool {
|
||||
if injected { return kind == "backchannel" }
|
||||
// live: sample ~250ms after onset; if speech already died away, it was a backchannel
|
||||
Thread.sleep(forTimeInterval: 0.25)
|
||||
lock.lock(); let rms = micRMS; lock.unlock()
|
||||
return rms < 0.015
|
||||
}
|
||||
|
||||
func persistResume(segs: [Segment], from: Int, reason: String) {
|
||||
let remaining = segs[from...].map { ["file": $0.file, "salience": $0.salience, "text": $0.text] as [String: Any] }
|
||||
let state: [String: Any] = ["resume_from": from, "reason": reason,
|
||||
"remaining": remaining, "ts": Date().timeIntervalSince1970]
|
||||
if let d = try? JSONSerialization.data(withJSONObject: state, options: [.prettyPrinted]) {
|
||||
try? d.write(to: URL(fileURLWithPath: resumePath))
|
||||
}
|
||||
disclose("CONVERSE: meaning-plan persisted (\(remaining.count) segments remain) — Neuron can resume the thread.")
|
||||
}
|
||||
func clearResume() { try? FileManager.default.removeItem(atPath: resumePath) }
|
||||
func finish() { player.stop(); if micLive { engine.inputNode.removeTap(onBus: 0) }; engine.stop() }
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ----------------------------------------------------------------------------
|
||||
func loadManifest(_ path: String) -> (segs: [Segment], utterance: String) {
|
||||
guard let d = FileManager.default.contents(atPath: path),
|
||||
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
|
||||
let arr = o["segments"] as? [[String: Any]] else { die("bad manifest: \(path)") }
|
||||
let segs = arr.map { Segment(file: $0["file"] as? String ?? "",
|
||||
salience: ($0["salience"] as? NSNumber)?.doubleValue ?? 0.5,
|
||||
text: $0["text"] as? String ?? "") }
|
||||
return (segs, o["utterance"] as? String ?? "")
|
||||
}
|
||||
|
||||
let args = CommandLine.arguments
|
||||
guard args.count >= 2 else {
|
||||
print("""
|
||||
periph — Neuron peripheral I/O (own-core, local, consent-gated)
|
||||
grant <camera|mic> grant a sensitive sense (Neuron-level consent)
|
||||
revoke <camera|mic> revoke it
|
||||
status show consent state
|
||||
speak <file.wav> SPEAK ALOUD (efferent) via the speaker
|
||||
tone <out.wav> [hz] [sec] own-core synth a test WAV (no deps)
|
||||
listen <sec> <out.wav> MIC capture (afferent) 16k mono
|
||||
see <out.jpg> CAMERA one frame (afferent)
|
||||
feat-audio <file.wav> extract compact voice/sound signature (for ingest)
|
||||
feat-image <file.jpg> extract compact scene-geometry (for ingest)
|
||||
ingest-audio <file.wav> <engramURL> capture -> descriptor -> engram node (geometry)
|
||||
ingest-image <file.jpg> <engramURL> capture -> descriptor -> engram node (geometry)
|
||||
voiceprint <voice.wav> extract voice-signature (F0 + formants F1-F5)
|
||||
imitate <voice.wav> <out.wav> speak back in that voice (LPC analysis-resynthesis)
|
||||
hear-imitate <sec> <out.wav> MIC -> extract signature -> imitate -> SPEAK ALOUD
|
||||
wav-info <file.wav> print WAV geometry
|
||||
converse <manifest.json> [--authority F] [--barge-at S[:backchannel|:bargein]] [--resume]
|
||||
full-duplex interruptible utterance
|
||||
""")
|
||||
exit(0)
|
||||
}
|
||||
|
||||
switch args[1] {
|
||||
case "grant":
|
||||
guard args.count >= 3 else { die("grant needs a device") }
|
||||
var g = Consent.load(); g[args[2]] = true; Consent.save(g)
|
||||
disclose("granted '\(args[2])' — the user consents; raw stream stays local, never egresses.")
|
||||
emit(["ok": true, "op": "grant", "device": args[2], "consent": g])
|
||||
case "revoke":
|
||||
guard args.count >= 3 else { die("revoke needs a device") }
|
||||
var g = Consent.load(); g[args[2]] = false; Consent.save(g)
|
||||
emit(["ok": true, "op": "revoke", "device": args[2], "consent": g])
|
||||
case "status":
|
||||
emit(["ok": true, "op": "status", "consent": Consent.load()])
|
||||
case "speak":
|
||||
guard args.count >= 3 else { die("speak needs a wav") }
|
||||
speak(args[2])
|
||||
case "tone":
|
||||
guard args.count >= 3 else { die("tone needs an out path") }
|
||||
let hz = args.count >= 4 ? Double(args[3]) ?? 220 : 220
|
||||
let sec = args.count >= 5 ? Double(args[4]) ?? 1.0 : 1.0
|
||||
let sr = 16000
|
||||
var s = [Int16](); s.reserveCapacity(Int(Double(sr)*sec))
|
||||
for i in 0..<Int(Double(sr)*sec) {
|
||||
let t = Double(i)/Double(sr)
|
||||
let env = min(1.0, min(t*20, (sec - t)*20)) // gentle attack/release
|
||||
s.append(Int16(env * 0.3 * 32767 * sin(2*Double.pi*hz*t)))
|
||||
}
|
||||
writeWav(URL(fileURLWithPath: args[2]), samples: s, sampleRate: sr)
|
||||
disclose("tone: wrote own-core \(sec)s @ \(hz)Hz WAV to \(args[2]).")
|
||||
emit(["ok": true, "op": "tone", "file": args[2], "hz": hz, "seconds": sec])
|
||||
case "listen":
|
||||
guard args.count >= 4 else { die("listen needs <sec> <out.wav>") }
|
||||
listen(seconds: Double(args[2]) ?? 3.0, out: args[3])
|
||||
case "see":
|
||||
guard args.count >= 3 else { die("see needs an out path") }
|
||||
see(out: args[2])
|
||||
case "feat-audio":
|
||||
guard args.count >= 3 else { die("feat-audio needs a wav") }
|
||||
featAudio(args[2])
|
||||
case "feat-image":
|
||||
guard args.count >= 3 else { die("feat-image needs an image") }
|
||||
featImage(args[2])
|
||||
case "ingest-audio":
|
||||
guard args.count >= 4 else { die("ingest-audio needs <wav> <engramURL>") }
|
||||
ingest(args[2], kind: "audio", engramURL: args[3])
|
||||
case "ingest-image":
|
||||
guard args.count >= 4 else { die("ingest-image needs <image> <engramURL>") }
|
||||
ingest(args[2], kind: "image", engramURL: args[3])
|
||||
case "voiceprint":
|
||||
guard args.count >= 3 else { die("voiceprint needs a wav") }
|
||||
let vp = voiceprint(args[2])
|
||||
disclose("VOICEPRINT: \(vp.content)")
|
||||
emit(["ok": true, "op": "voiceprint", "file": args[2], "f0_hz": vp.f0,
|
||||
"f0_range": [vp.f0lo, vp.f0hi], "formants_hz": vp.formants.map { $0.0 },
|
||||
"bandwidths_hz": vp.formants.map { $0.1 }, "content": vp.content,
|
||||
"ingest": ["node_type": "Observation", "tier": "Episodic", "content": vp.content]])
|
||||
case "imitate":
|
||||
guard args.count >= 4 else { die("imitate needs <voice.wav> <out.wav>") }
|
||||
imitate(inPath: args[2], outPath: args[3])
|
||||
case "hear-imitate":
|
||||
guard args.count >= 4 else { die("hear-imitate needs <sec> <out.wav>") }
|
||||
let secs = Double(args[2]) ?? 4.0
|
||||
let outp = args[3]
|
||||
let capp = outp.replacingOccurrences(of: ".wav", with: "") + ".heard.wav"
|
||||
disclose("HEAR-IMITATE: open the ear, listen \(secs)s, grab the voice, speak it back.")
|
||||
listen(seconds: secs, out: capp) // afferent: hear the voice
|
||||
imitate(inPath: capp, outPath: outp) // extract signature + resynthesize
|
||||
speak(outp) // efferent: speak back ALOUD in that voice
|
||||
case "wav-info":
|
||||
guard args.count >= 3, let i = wavInfo(args[2]) else { die("wav-info needs a readable wav") }
|
||||
disclose("WAV \(args[2]): \(i.sampleRate)Hz \(i.channels)ch \(i.bits)bit \(i.frames) frames")
|
||||
emit(["ok": true, "op": "wav-info", "sample_rate": i.sampleRate, "channels": i.channels,
|
||||
"bits": i.bits, "frames": i.frames,
|
||||
"seconds": Double(i.frames)/Double(max(i.sampleRate,1))])
|
||||
case "converse":
|
||||
guard args.count >= 3 else { die("converse needs a manifest") }
|
||||
let (segs, utter) = loadManifest(args[2])
|
||||
var authority = 0.5
|
||||
var bargeAt: Double? = nil
|
||||
var bargeKind = "bargein"
|
||||
var resume = false
|
||||
var liveMic = false
|
||||
var i = 3
|
||||
while i < args.count {
|
||||
switch args[i] {
|
||||
case "--authority": if i+1 < args.count { authority = Double(args[i+1]) ?? 0.5; i += 1 }
|
||||
case "--barge-at":
|
||||
if i+1 < args.count {
|
||||
let parts = args[i+1].split(separator: ":")
|
||||
bargeAt = Double(parts[0]) ?? nil
|
||||
if parts.count > 1 { bargeKind = String(parts[1]) }
|
||||
i += 1
|
||||
}
|
||||
case "--resume": resume = true
|
||||
case "--live-mic": liveMic = true
|
||||
default: break
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
let resumePath = (ProcessInfo.processInfo.environment["PERIPH_HOME"]
|
||||
?? FileManager.default.currentDirectoryPath + "/peripheral") + "/.resume.json"
|
||||
var startIndex = 0
|
||||
var runSegs = segs
|
||||
if resume, let d = FileManager.default.contents(atPath: resumePath),
|
||||
let o = try? JSONSerialization.jsonObject(with: d) as? [String: Any],
|
||||
let rem = o["remaining"] as? [[String: Any]] {
|
||||
runSegs = rem.map { Segment(file: $0["file"] as? String ?? "",
|
||||
salience: ($0["salience"] as? NSNumber)?.doubleValue ?? 0.5,
|
||||
text: $0["text"] as? String ?? "") }
|
||||
startIndex = 0
|
||||
disclose("CONVERSE: resuming — \"as I was saying...\" (\(runSegs.count) segments left).")
|
||||
emit(["op": "converse", "event": "resume", "remaining": runSegs.count])
|
||||
}
|
||||
if runSegs.isEmpty { die("no segments to speak") }
|
||||
disclose("CONVERSE: utterance = \"\(utter)\" (\(runSegs.count) segments).")
|
||||
let convo = Conversation(resumePath: resumePath)
|
||||
convo.run(runSegs, interrupterAuthority: authority,
|
||||
injectBargeAt: bargeAt, injectKind: bargeKind, startIndex: startIndex, liveMic: liveMic)
|
||||
default:
|
||||
die("unknown command: \(args[1])")
|
||||
}
|
||||
Reference in New Issue
Block a user