nexo-brain 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
- package/src/__pycache__/cognitive.cpython-314.pyc +0 -0
- package/src/__pycache__/db.cpython-314.pyc +0 -0
- package/src/__pycache__/evolution_cycle.cpython-314.pyc +0 -0
- package/src/__pycache__/kg_populate.cpython-314.pyc +0 -0
- package/src/__pycache__/knowledge_graph.cpython-314.pyc +0 -0
- package/src/__pycache__/maintenance.cpython-314.pyc +0 -0
- package/src/__pycache__/migrate_embeddings.cpython-314.pyc +0 -0
- package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
- package/src/__pycache__/server.cpython-314.pyc +0 -0
- package/src/__pycache__/storage_router.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_coordination.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_credentials.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_learnings.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_menu.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_reminders.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_reminders_crud.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_sessions.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_task_history.cpython-314.pyc +0 -0
- package/src/cognitive.py +63 -14
- package/src/dashboard/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/dashboard/__pycache__/app.cpython-314.pyc +0 -0
- package/src/dashboard/app.py +1 -1
- package/src/db.py +29 -13
- package/src/evolution_cycle.py +72 -94
- package/src/hooks/__pycache__/auto_capture.cpython-314.pyc +0 -0
- package/src/hooks/session-start.sh +5 -2
- package/src/hooks/session-stop.sh +79 -133
- package/src/knowledge_graph.py +3 -3
- package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/agents.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/artifact_registry.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/backup.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/cognitive_memory.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/core_rules.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/cortex.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/entities.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/episodic_memory.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/evolution.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/guard.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/knowledge_graph_tools.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/preferences.cpython-314.pyc +0 -0
- package/src/plugins/artifact_registry.py +450 -0
- package/src/plugins/cognitive_memory.py +9 -9
- package/src/plugins/episodic_memory.py +8 -8
- package/src/plugins/evolution.py +4 -4
- package/src/plugins/guard.py +26 -2
- package/src/rules/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/rules/__pycache__/migrate.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/check-context.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
- package/src/scripts/nexo-brain-activation.sh +140 -0
- package/src/scripts/nexo-evolution-run.py +1 -1
- package/src/scripts/nexo-followup-hygiene.py +107 -0
- package/src/scripts/nexo-postmortem-consolidator.py +25 -25
- package/src/scripts/nexo-pre-commit.py +118 -0
- package/src/scripts/nexo-proactive-dashboard.py +342 -0
- package/src/scripts/nexo-runtime-preflight.py +270 -0
- package/src/scripts/nexo-send-email.py +25 -0
- package/src/scripts/nexo-send-reply.py +176 -0
- package/src/scripts/nexo-snapshot-restore.sh +34 -0
- package/src/scripts/nexo-watchdog-smoke.py +114 -0
- package/src/server.py +5 -5
- package/src/tools_coordination.py +3 -3
- package/src/tools_learnings.py +1 -1
- package/src/tools_menu.py +31 -49
- package/src/tools_reminders_crud.py +1 -1
- package/src/tools_sessions.py +12 -12
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/migrate 2.py +0 -207
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
"""Artifact Registry plugin — structured index of things NEXO creates/deploys.
|
|
2
|
+
|
|
3
|
+
Solves 'recent work amnesia': NEXO builds services, dashboards, scripts, APIs
|
|
4
|
+
but can't find them hours later because semantic search ('backend') doesn't
|
|
5
|
+
match operational terms ('FastAPI localhost:6174').
|
|
6
|
+
|
|
7
|
+
Architecture (from 3-way AI debate — GPT-5.4 + Gemini 3.1 Pro + Claude Opus 4.6):
|
|
8
|
+
1. Structured SQLite table with aliases, ports, paths, run commands
|
|
9
|
+
2. Retrieval ladder: exact alias → port/path match → fuzzy token → semantic fallback
|
|
10
|
+
3. User-language alias learning: when the user says 'backend' and it resolves
|
|
11
|
+
to dashboard:6174, store that mapping for O(1) next time
|
|
12
|
+
4. Temporal filtering: 'last night' → hard SQL constraint before any search
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import datetime
|
|
17
|
+
from db import get_db
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Valid artifact kinds
|
|
21
|
+
VALID_KINDS = {
|
|
22
|
+
'service', 'dashboard', 'script', 'api', 'cron', 'website',
|
|
23
|
+
'database', 'repo', 'config', 'tool', 'plugin', 'other',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
VALID_STATES = {'active', 'inactive', 'broken', 'archived'}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cognitive_ingest_safe(content, source_type, source_id="", source_title="", domain=""):
|
|
30
|
+
"""Ingest to cognitive STM. Silently fails if cognitive engine unavailable."""
|
|
31
|
+
try:
|
|
32
|
+
import cognitive
|
|
33
|
+
cognitive.ingest(content, source_type, source_id, source_title, domain)
|
|
34
|
+
except Exception:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def handle_artifact_create(
|
|
39
|
+
kind: str,
|
|
40
|
+
canonical_name: str,
|
|
41
|
+
aliases: str = '[]',
|
|
42
|
+
description: str = '',
|
|
43
|
+
uri: str = '',
|
|
44
|
+
ports: str = '[]',
|
|
45
|
+
paths: str = '[]',
|
|
46
|
+
run_cmd: str = '',
|
|
47
|
+
repo: str = '',
|
|
48
|
+
domain: str = '',
|
|
49
|
+
session_id: str = '',
|
|
50
|
+
metadata: str = '{}',
|
|
51
|
+
) -> str:
|
|
52
|
+
"""Register a new artifact (service, dashboard, script, API, etc.).
|
|
53
|
+
|
|
54
|
+
Call this whenever NEXO creates, deploys, or discovers a runnable/accessible artifact.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
kind: Type — service, dashboard, script, api, cron, website, database, repo, config, tool, plugin, other
|
|
58
|
+
canonical_name: Primary name (e.g., 'NEXO Brain Dashboard')
|
|
59
|
+
aliases: JSON array of alternative names users might use (e.g., '["backend", "dashboard", "nexo web"]')
|
|
60
|
+
description: What it does (1-2 sentences)
|
|
61
|
+
uri: Access URL or address (e.g., 'localhost:6174', 'nexo-brain.com')
|
|
62
|
+
ports: JSON array of ports (e.g., '[6174]')
|
|
63
|
+
paths: JSON array of file paths (e.g., '["/Users/x/nexo/src/dashboard/app.py"]')
|
|
64
|
+
run_cmd: Command to start/open it (e.g., 'python3 -m dashboard.app --port 6174')
|
|
65
|
+
repo: Repository path or URL
|
|
66
|
+
domain: Project domain (nexo, my-project, project-a, project-b, etc.)
|
|
67
|
+
session_id: Current session ID
|
|
68
|
+
metadata: JSON object with extra key-value pairs
|
|
69
|
+
"""
|
|
70
|
+
if kind not in VALID_KINDS:
|
|
71
|
+
return f"ERROR: kind must be one of: {', '.join(sorted(VALID_KINDS))}"
|
|
72
|
+
|
|
73
|
+
# Parse aliases
|
|
74
|
+
try:
|
|
75
|
+
alias_list = json.loads(aliases) if aliases and aliases != '[]' else []
|
|
76
|
+
except (json.JSONDecodeError, TypeError):
|
|
77
|
+
alias_list = [a.strip() for a in aliases.split(',') if a.strip()]
|
|
78
|
+
|
|
79
|
+
conn = get_db()
|
|
80
|
+
cur = conn.execute(
|
|
81
|
+
"""INSERT INTO artifact_registry
|
|
82
|
+
(kind, canonical_name, aliases, description, uri, ports, paths,
|
|
83
|
+
run_cmd, repo, domain, state, session_id, metadata)
|
|
84
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)""",
|
|
85
|
+
(kind, canonical_name, json.dumps(alias_list), description, uri, ports,
|
|
86
|
+
paths, run_cmd, repo, domain, session_id, metadata),
|
|
87
|
+
)
|
|
88
|
+
artifact_id = cur.lastrowid
|
|
89
|
+
conn.commit()
|
|
90
|
+
|
|
91
|
+
# Insert aliases into lookup table
|
|
92
|
+
for alias in alias_list + [canonical_name.lower()]:
|
|
93
|
+
alias_clean = alias.strip().lower()
|
|
94
|
+
if alias_clean:
|
|
95
|
+
try:
|
|
96
|
+
conn.execute(
|
|
97
|
+
"INSERT OR IGNORE INTO artifact_aliases (artifact_id, phrase, source) VALUES (?, ?, 'create')",
|
|
98
|
+
(artifact_id, alias_clean),
|
|
99
|
+
)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
conn.commit()
|
|
103
|
+
|
|
104
|
+
# Ingest to cognitive memory
|
|
105
|
+
content = f"Artifact: {canonical_name} ({kind}). {description}. URI: {uri}. Aliases: {', '.join(alias_list)}"
|
|
106
|
+
_cognitive_ingest_safe(content, "artifact", f"A{artifact_id}", canonical_name[:80], domain)
|
|
107
|
+
|
|
108
|
+
return f"Artifact #{artifact_id} created: {canonical_name} ({kind}) — {uri or 'no URI'}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def handle_artifact_find(query: str, kind: str = '', state: str = 'active') -> str:
|
|
112
|
+
"""Find artifacts using the retrieval ladder: exact alias → port/path → fuzzy token → all recent.
|
|
113
|
+
|
|
114
|
+
This is the PRIMARY retrieval tool. Use it when the user references something
|
|
115
|
+
they or NEXO built/deployed/created. Designed for natural language like
|
|
116
|
+
'the backend', 'that script from yesterday', 'localhost something'.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
query: What to search for — name, alias, port, path, or description fragment
|
|
120
|
+
kind: Filter by kind (optional)
|
|
121
|
+
state: Filter by state — default 'active'. Use 'all' for everything.
|
|
122
|
+
"""
|
|
123
|
+
conn = get_db()
|
|
124
|
+
results = []
|
|
125
|
+
query_lower = query.strip().lower()
|
|
126
|
+
|
|
127
|
+
state_filter = "AND state = ?" if state != 'all' else ""
|
|
128
|
+
state_params = (state,) if state != 'all' else ()
|
|
129
|
+
|
|
130
|
+
kind_filter = "AND kind = ?" if kind else ""
|
|
131
|
+
kind_params = (kind,) if kind else ()
|
|
132
|
+
|
|
133
|
+
extra_filters = state_filter + " " + kind_filter
|
|
134
|
+
extra_params = state_params + kind_params
|
|
135
|
+
|
|
136
|
+
# --- STAGE 1: Exact alias match (fastest, O(1)) ---
|
|
137
|
+
rows = conn.execute(
|
|
138
|
+
f"""SELECT DISTINCT r.* FROM artifact_registry r
|
|
139
|
+
JOIN artifact_aliases a ON a.artifact_id = r.id
|
|
140
|
+
WHERE a.phrase = ? {extra_filters}
|
|
141
|
+
ORDER BY r.last_touched_at DESC LIMIT 5""",
|
|
142
|
+
(query_lower,) + extra_params,
|
|
143
|
+
).fetchall()
|
|
144
|
+
if rows:
|
|
145
|
+
results = [dict(r) for r in rows]
|
|
146
|
+
return _format_results(results, "alias match", query)
|
|
147
|
+
|
|
148
|
+
# --- STAGE 2: Port or URI match ---
|
|
149
|
+
rows = conn.execute(
|
|
150
|
+
f"""SELECT * FROM artifact_registry
|
|
151
|
+
WHERE (uri LIKE ? OR ports LIKE ?) {extra_filters}
|
|
152
|
+
ORDER BY last_touched_at DESC LIMIT 5""",
|
|
153
|
+
(f"%{query_lower}%", f"%{query_lower}%") + extra_params,
|
|
154
|
+
).fetchall()
|
|
155
|
+
if rows:
|
|
156
|
+
results = [dict(r) for r in rows]
|
|
157
|
+
return _format_results(results, "URI/port match", query)
|
|
158
|
+
|
|
159
|
+
# --- STAGE 3: Path match ---
|
|
160
|
+
rows = conn.execute(
|
|
161
|
+
f"""SELECT * FROM artifact_registry
|
|
162
|
+
WHERE paths LIKE ? {extra_filters}
|
|
163
|
+
ORDER BY last_touched_at DESC LIMIT 5""",
|
|
164
|
+
(f"%{query_lower}%",) + extra_params,
|
|
165
|
+
).fetchall()
|
|
166
|
+
if rows:
|
|
167
|
+
results = [dict(r) for r in rows]
|
|
168
|
+
return _format_results(results, "path match", query)
|
|
169
|
+
|
|
170
|
+
# --- STAGE 4: Fuzzy token match on name, description, aliases ---
|
|
171
|
+
tokens = query_lower.split()
|
|
172
|
+
if tokens:
|
|
173
|
+
conditions = " AND ".join(
|
|
174
|
+
"(LOWER(canonical_name) LIKE ? OR LOWER(description) LIKE ? OR LOWER(aliases) LIKE ?)"
|
|
175
|
+
for _ in tokens
|
|
176
|
+
)
|
|
177
|
+
params = []
|
|
178
|
+
for t in tokens:
|
|
179
|
+
p = f"%{t}%"
|
|
180
|
+
params.extend([p, p, p])
|
|
181
|
+
rows = conn.execute(
|
|
182
|
+
f"""SELECT * FROM artifact_registry
|
|
183
|
+
WHERE {conditions} {extra_filters}
|
|
184
|
+
ORDER BY last_touched_at DESC LIMIT 10""",
|
|
185
|
+
tuple(params) + extra_params,
|
|
186
|
+
).fetchall()
|
|
187
|
+
if rows:
|
|
188
|
+
results = [dict(r) for r in rows]
|
|
189
|
+
return _format_results(results, "token match", query)
|
|
190
|
+
|
|
191
|
+
# --- STAGE 5: Recent artifacts (last 72h) as fallback ---
|
|
192
|
+
cutoff = (datetime.datetime.now() - datetime.timedelta(hours=72)).isoformat()
|
|
193
|
+
rows = conn.execute(
|
|
194
|
+
f"""SELECT * FROM artifact_registry
|
|
195
|
+
WHERE last_touched_at >= ? {extra_filters}
|
|
196
|
+
ORDER BY last_touched_at DESC LIMIT 10""",
|
|
197
|
+
(cutoff,) + extra_params,
|
|
198
|
+
).fetchall()
|
|
199
|
+
if rows:
|
|
200
|
+
results = [dict(r) for r in rows]
|
|
201
|
+
return _format_results(results, "recent (72h)", query)
|
|
202
|
+
|
|
203
|
+
return f"No artifacts found for '{query}'. Use artifact_list to see all registered artifacts."
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def handle_artifact_update(
|
|
207
|
+
id: int,
|
|
208
|
+
canonical_name: str = '',
|
|
209
|
+
aliases: str = '',
|
|
210
|
+
description: str = '',
|
|
211
|
+
uri: str = '',
|
|
212
|
+
ports: str = '',
|
|
213
|
+
paths: str = '',
|
|
214
|
+
run_cmd: str = '',
|
|
215
|
+
state: str = '',
|
|
216
|
+
domain: str = '',
|
|
217
|
+
metadata: str = '',
|
|
218
|
+
) -> str:
|
|
219
|
+
"""Update an artifact. Only non-empty fields are changed.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
id: Artifact ID to update
|
|
223
|
+
canonical_name: New primary name
|
|
224
|
+
aliases: New JSON array of aliases (replaces existing)
|
|
225
|
+
description: New description
|
|
226
|
+
uri: New URI
|
|
227
|
+
ports: New ports JSON array
|
|
228
|
+
paths: New paths JSON array
|
|
229
|
+
run_cmd: New run command
|
|
230
|
+
state: New state (active, inactive, broken, archived)
|
|
231
|
+
domain: New domain
|
|
232
|
+
metadata: New metadata JSON (merged with existing)
|
|
233
|
+
"""
|
|
234
|
+
conn = get_db()
|
|
235
|
+
row = conn.execute("SELECT * FROM artifact_registry WHERE id = ?", (id,)).fetchone()
|
|
236
|
+
if not row:
|
|
237
|
+
return f"ERROR: Artifact #{id} not found."
|
|
238
|
+
|
|
239
|
+
updates = []
|
|
240
|
+
params = []
|
|
241
|
+
|
|
242
|
+
if canonical_name:
|
|
243
|
+
updates.append("canonical_name = ?"); params.append(canonical_name)
|
|
244
|
+
if description:
|
|
245
|
+
updates.append("description = ?"); params.append(description)
|
|
246
|
+
if uri:
|
|
247
|
+
updates.append("uri = ?"); params.append(uri)
|
|
248
|
+
if ports:
|
|
249
|
+
updates.append("ports = ?"); params.append(ports)
|
|
250
|
+
if paths:
|
|
251
|
+
updates.append("paths = ?"); params.append(paths)
|
|
252
|
+
if run_cmd:
|
|
253
|
+
updates.append("run_cmd = ?"); params.append(run_cmd)
|
|
254
|
+
if domain:
|
|
255
|
+
updates.append("domain = ?"); params.append(domain)
|
|
256
|
+
if state:
|
|
257
|
+
if state not in VALID_STATES:
|
|
258
|
+
return f"ERROR: state must be one of: {', '.join(sorted(VALID_STATES))}"
|
|
259
|
+
updates.append("state = ?"); params.append(state)
|
|
260
|
+
if metadata:
|
|
261
|
+
try:
|
|
262
|
+
existing = json.loads(row["metadata"] or '{}')
|
|
263
|
+
new = json.loads(metadata)
|
|
264
|
+
existing.update(new)
|
|
265
|
+
updates.append("metadata = ?"); params.append(json.dumps(existing))
|
|
266
|
+
except (json.JSONDecodeError, TypeError):
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
if aliases:
|
|
270
|
+
try:
|
|
271
|
+
alias_list = json.loads(aliases) if aliases.startswith('[') else [a.strip() for a in aliases.split(',')]
|
|
272
|
+
except (json.JSONDecodeError, TypeError):
|
|
273
|
+
alias_list = [a.strip() for a in aliases.split(',')]
|
|
274
|
+
updates.append("aliases = ?"); params.append(json.dumps(alias_list))
|
|
275
|
+
# Rebuild alias lookup table
|
|
276
|
+
conn.execute("DELETE FROM artifact_aliases WHERE artifact_id = ?", (id,))
|
|
277
|
+
for alias in alias_list:
|
|
278
|
+
alias_clean = alias.strip().lower()
|
|
279
|
+
if alias_clean:
|
|
280
|
+
conn.execute(
|
|
281
|
+
"INSERT OR IGNORE INTO artifact_aliases (artifact_id, phrase, source) VALUES (?, ?, 'update')",
|
|
282
|
+
(id, alias_clean),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if not updates:
|
|
286
|
+
return "Nothing to update."
|
|
287
|
+
|
|
288
|
+
updates.append("last_touched_at = datetime('now')")
|
|
289
|
+
params.append(id)
|
|
290
|
+
conn.execute(f"UPDATE artifact_registry SET {', '.join(updates)} WHERE id = ?", tuple(params))
|
|
291
|
+
conn.commit()
|
|
292
|
+
return f"Artifact #{id} updated."
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def handle_artifact_learn_alias(id: int, phrase: str) -> str:
|
|
296
|
+
"""Learn a new alias from user language. Call this when the user refers to an
|
|
297
|
+
artifact with a term not yet registered (e.g., the user says 'backend' for dashboard:6174).
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
id: Artifact ID
|
|
301
|
+
phrase: The user's term (e.g., 'backend', 'that api thing')
|
|
302
|
+
"""
|
|
303
|
+
conn = get_db()
|
|
304
|
+
row = conn.execute("SELECT * FROM artifact_registry WHERE id = ?", (id,)).fetchone()
|
|
305
|
+
if not row:
|
|
306
|
+
return f"ERROR: Artifact #{id} not found."
|
|
307
|
+
|
|
308
|
+
phrase_clean = phrase.strip().lower()
|
|
309
|
+
if not phrase_clean:
|
|
310
|
+
return "ERROR: Empty phrase."
|
|
311
|
+
|
|
312
|
+
# Add to alias lookup table
|
|
313
|
+
conn.execute(
|
|
314
|
+
"INSERT OR IGNORE INTO artifact_aliases (artifact_id, phrase, source) VALUES (?, ?, 'user_language')",
|
|
315
|
+
(id, phrase_clean),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Also add to the artifact's aliases JSON array
|
|
319
|
+
try:
|
|
320
|
+
existing = json.loads(row["aliases"] or '[]')
|
|
321
|
+
except (json.JSONDecodeError, TypeError):
|
|
322
|
+
existing = []
|
|
323
|
+
if phrase_clean not in [a.lower() for a in existing]:
|
|
324
|
+
existing.append(phrase_clean)
|
|
325
|
+
conn.execute(
|
|
326
|
+
"UPDATE artifact_registry SET aliases = ?, last_touched_at = datetime('now') WHERE id = ?",
|
|
327
|
+
(json.dumps(existing), id),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
conn.commit()
|
|
331
|
+
return f"Alias '{phrase_clean}' learned for artifact #{id} ({row['canonical_name']})."
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def handle_artifact_list(kind: str = '', state: str = 'active', recent_hours: int = 0) -> str:
|
|
335
|
+
"""List all artifacts, optionally filtered.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
kind: Filter by kind (service, dashboard, script, etc.)
|
|
339
|
+
state: Filter by state — 'active' (default), 'all', 'inactive', 'broken', 'archived'
|
|
340
|
+
recent_hours: If >0, only show artifacts touched in the last N hours
|
|
341
|
+
"""
|
|
342
|
+
conn = get_db()
|
|
343
|
+
conditions = []
|
|
344
|
+
params = []
|
|
345
|
+
|
|
346
|
+
if state != 'all':
|
|
347
|
+
conditions.append("state = ?"); params.append(state)
|
|
348
|
+
if kind:
|
|
349
|
+
conditions.append("kind = ?"); params.append(kind)
|
|
350
|
+
if recent_hours > 0:
|
|
351
|
+
cutoff = (datetime.datetime.now() - datetime.timedelta(hours=recent_hours)).isoformat()
|
|
352
|
+
conditions.append("last_touched_at >= ?"); params.append(cutoff)
|
|
353
|
+
|
|
354
|
+
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
|
355
|
+
rows = conn.execute(
|
|
356
|
+
f"SELECT * FROM artifact_registry {where} ORDER BY last_touched_at DESC",
|
|
357
|
+
tuple(params),
|
|
358
|
+
).fetchall()
|
|
359
|
+
|
|
360
|
+
if not rows:
|
|
361
|
+
filters = []
|
|
362
|
+
if kind: filters.append(f"kind={kind}")
|
|
363
|
+
if state != 'all': filters.append(f"state={state}")
|
|
364
|
+
if recent_hours: filters.append(f"last {recent_hours}h")
|
|
365
|
+
return f"No artifacts found{' (' + ', '.join(filters) + ')' if filters else ''}."
|
|
366
|
+
|
|
367
|
+
lines = [f"ARTIFACT REGISTRY ({len(rows)}):"]
|
|
368
|
+
for r in rows:
|
|
369
|
+
r = dict(r)
|
|
370
|
+
aliases_str = ""
|
|
371
|
+
try:
|
|
372
|
+
aliases = json.loads(r.get("aliases", "[]"))
|
|
373
|
+
if aliases:
|
|
374
|
+
aliases_str = f" aka [{', '.join(aliases[:3])}]"
|
|
375
|
+
except (json.JSONDecodeError, TypeError):
|
|
376
|
+
pass
|
|
377
|
+
uri_str = f" → {r['uri']}" if r.get("uri") else ""
|
|
378
|
+
cmd_str = f" | cmd: {r['run_cmd'][:60]}" if r.get("run_cmd") else ""
|
|
379
|
+
touched = r.get("last_touched_at", "")[:16]
|
|
380
|
+
lines.append(
|
|
381
|
+
f" #{r['id']} [{r['kind']}] {r['canonical_name']}{aliases_str}{uri_str}{cmd_str} "
|
|
382
|
+
f"({r['state']}, {touched})"
|
|
383
|
+
)
|
|
384
|
+
return "\n".join(lines)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def handle_artifact_delete(id: int) -> str:
|
|
388
|
+
"""Delete an artifact from the registry.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
id: Artifact ID to delete
|
|
392
|
+
"""
|
|
393
|
+
conn = get_db()
|
|
394
|
+
row = conn.execute("SELECT canonical_name FROM artifact_registry WHERE id = ?", (id,)).fetchone()
|
|
395
|
+
if not row:
|
|
396
|
+
return f"ERROR: Artifact #{id} not found."
|
|
397
|
+
name = row["canonical_name"]
|
|
398
|
+
conn.execute("DELETE FROM artifact_aliases WHERE artifact_id = ?", (id,))
|
|
399
|
+
conn.execute("DELETE FROM artifact_registry WHERE id = ?", (id,))
|
|
400
|
+
conn.commit()
|
|
401
|
+
return f"Artifact #{id} ({name}) deleted."
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _format_results(results, method, query):
|
|
405
|
+
"""Format search results for display."""
|
|
406
|
+
lines = [f"ARTIFACTS FOUND ({len(results)}, via {method} for '{query}'):"]
|
|
407
|
+
for r in results:
|
|
408
|
+
aliases_str = ""
|
|
409
|
+
try:
|
|
410
|
+
aliases = json.loads(r.get("aliases", "[]"))
|
|
411
|
+
if aliases:
|
|
412
|
+
aliases_str = f" aka [{', '.join(aliases[:4])}]"
|
|
413
|
+
except (json.JSONDecodeError, TypeError):
|
|
414
|
+
pass
|
|
415
|
+
uri_str = f" → {r['uri']}" if r.get("uri") else ""
|
|
416
|
+
cmd_str = f"\n Run: {r['run_cmd']}" if r.get("run_cmd") else ""
|
|
417
|
+
paths_str = ""
|
|
418
|
+
try:
|
|
419
|
+
paths = json.loads(r.get("paths", "[]"))
|
|
420
|
+
if paths:
|
|
421
|
+
paths_str = f"\n Paths: {', '.join(paths[:3])}"
|
|
422
|
+
except (json.JSONDecodeError, TypeError):
|
|
423
|
+
pass
|
|
424
|
+
touched = r.get("last_touched_at", "")[:16]
|
|
425
|
+
lines.append(
|
|
426
|
+
f" #{r['id']} [{r['kind']}] {r['canonical_name']}{aliases_str}{uri_str} "
|
|
427
|
+
f"({r['state']}, {touched}){cmd_str}{paths_str}"
|
|
428
|
+
)
|
|
429
|
+
return "\n".join(lines)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# Plugin registration — TOOLS array consumed by plugin_loader.py
|
|
433
|
+
TOOLS = [
|
|
434
|
+
(handle_artifact_create, "nexo_artifact_create",
|
|
435
|
+
"Register a new artifact (service, dashboard, script, API, etc.) in the Artifact Registry. "
|
|
436
|
+
"Call this whenever NEXO creates, deploys, or discovers a runnable/accessible artifact."),
|
|
437
|
+
(handle_artifact_find, "nexo_artifact_find",
|
|
438
|
+
"Find artifacts using the retrieval ladder: exact alias → port/path → fuzzy token → recent. "
|
|
439
|
+
"PRIMARY retrieval tool for when users reference something built/deployed. Handles natural "
|
|
440
|
+
"language like 'the backend', 'that script', 'localhost something'."),
|
|
441
|
+
(handle_artifact_update, "nexo_artifact_update",
|
|
442
|
+
"Update an existing artifact. Only non-empty fields are changed."),
|
|
443
|
+
(handle_artifact_learn_alias, "nexo_artifact_learn_alias",
|
|
444
|
+
"Learn a new alias from user language. Call when the user refers to an artifact with "
|
|
445
|
+
"an unregistered term (e.g., 'backend' for the NEXO Brain Dashboard)."),
|
|
446
|
+
(handle_artifact_list, "nexo_artifact_list",
|
|
447
|
+
"List all registered artifacts, optionally filtered by kind, state, or recency."),
|
|
448
|
+
(handle_artifact_delete, "nexo_artifact_delete",
|
|
449
|
+
"Delete an artifact from the registry."),
|
|
450
|
+
]
|
|
@@ -30,7 +30,7 @@ def handle_cognitive_retrieve(
|
|
|
30
30
|
min_score: Minimum cosine similarity score (default 0.5)
|
|
31
31
|
stores: Which store to search — "both", "stm", or "ltm" (default "both")
|
|
32
32
|
source_type: Filter by source type e.g. "change", "learning", "diary" (default: all)
|
|
33
|
-
domain: Filter by domain e.g. "
|
|
33
|
+
domain: Filter by domain e.g. "my-project", "shopify" (default: all)
|
|
34
34
|
include_archived: If True, also search archived memories (default False)
|
|
35
35
|
use_hyde: If True, use HyDE query expansion — embeds 3-5 query variants and searches with centroid. Better recall for conceptual queries. (default False)
|
|
36
36
|
spreading_depth: If >0, boost co-activated neighbors (memories frequently retrieved together). 1=direct neighbors only. (default 0)
|
|
@@ -222,13 +222,13 @@ def handle_cognitive_metrics(days: int = 7) -> str:
|
|
|
222
222
|
|
|
223
223
|
|
|
224
224
|
def handle_cognitive_sentiment(text: str) -> str:
|
|
225
|
-
"""Detect
|
|
225
|
+
"""Detect the user's sentiment from his text. Returns mood, intensity, and guidance.
|
|
226
226
|
|
|
227
|
-
Call this with
|
|
227
|
+
Call this with the user's recent message to adapt NEXO's tone and behavior.
|
|
228
228
|
Also logs the sentiment for historical tracking.
|
|
229
229
|
|
|
230
230
|
Args:
|
|
231
|
-
text:
|
|
231
|
+
text: The user's recent message or instruction
|
|
232
232
|
"""
|
|
233
233
|
result = cognitive.log_sentiment(text)
|
|
234
234
|
trust = cognitive.get_trust_score()
|
|
@@ -294,8 +294,8 @@ def handle_cognitive_trust(event: str = '', context: str = '', delta: float = No
|
|
|
294
294
|
def handle_cognitive_dissonance(instruction: str, force: bool = False) -> str:
|
|
295
295
|
"""Detect cognitive dissonance: find established memories that conflict with a new instruction.
|
|
296
296
|
|
|
297
|
-
Use BEFORE applying a new preference or rule from
|
|
298
|
-
existing knowledge. If conflicts found, verbalize them and ask
|
|
297
|
+
Use BEFORE applying a new preference or rule from the user that might contradict
|
|
298
|
+
existing knowledge. If conflicts found, verbalize them and ask the user to resolve.
|
|
299
299
|
|
|
300
300
|
Args:
|
|
301
301
|
instruction: The new instruction or preference to check against LTM
|
|
@@ -328,7 +328,7 @@ def handle_cognitive_dissonance(instruction: str, force: bool = False) -> str:
|
|
|
328
328
|
lines.append("")
|
|
329
329
|
|
|
330
330
|
lines.append("RESOLVE with nexo_cognitive_resolve, or use force=True to skip:")
|
|
331
|
-
lines.append(" - 'paradigm_shift':
|
|
331
|
+
lines.append(" - 'paradigm_shift': The user changed their mind permanently.")
|
|
332
332
|
lines.append(" - 'exception': One-time override. Old memory stays.")
|
|
333
333
|
lines.append(" - 'override': Old memory was wrong.")
|
|
334
334
|
|
|
@@ -336,7 +336,7 @@ def handle_cognitive_dissonance(instruction: str, force: bool = False) -> str:
|
|
|
336
336
|
|
|
337
337
|
|
|
338
338
|
def handle_cognitive_resolve(memory_id: int, resolution: str, context: str = '') -> str:
|
|
339
|
-
"""Resolve a cognitive dissonance by applying
|
|
339
|
+
"""Resolve a cognitive dissonance by applying the user's decision.
|
|
340
340
|
|
|
341
341
|
Args:
|
|
342
342
|
memory_id: The LTM memory ID from the dissonance detection
|
|
@@ -546,7 +546,7 @@ TOOLS = [
|
|
|
546
546
|
(handle_cognitive_metrics, "nexo_cognitive_metrics", "Performance metrics: retrieval relevance %, repeat error rate, multilingual recommendation (spec section 9)"),
|
|
547
547
|
(handle_cognitive_dissonance, "nexo_cognitive_dissonance", "Detect conflicts between a new instruction and established LTM memories. force=True to skip discussion."),
|
|
548
548
|
(handle_cognitive_resolve, "nexo_cognitive_resolve", "Resolve a cognitive dissonance: paradigm_shift, exception, or override."),
|
|
549
|
-
(handle_cognitive_sentiment, "nexo_cognitive_sentiment", "Detect
|
|
549
|
+
(handle_cognitive_sentiment, "nexo_cognitive_sentiment", "Detect the user's sentiment and get tone guidance. Also logs for tracking."),
|
|
550
550
|
(handle_cognitive_trust, "nexo_cognitive_trust", "View or adjust trust score (0-100). Without args: view. With event: adjust."),
|
|
551
551
|
(handle_cognitive_pin, "nexo_cognitive_pin", "Pin a memory — never decays, boosted +0.2 in search results."),
|
|
552
552
|
(handle_cognitive_snooze, "nexo_cognitive_snooze", "Snooze a memory — hidden from searches until a date, then auto-restores."),
|
|
@@ -27,7 +27,7 @@ def handle_decision_log(domain: str, decision: str, alternatives: str = '',
|
|
|
27
27
|
"""Log a non-trivial decision with reasoning context.
|
|
28
28
|
|
|
29
29
|
Args:
|
|
30
|
-
domain: Area (ads, shopify, server,
|
|
30
|
+
domain: Area (ads, shopify, server, my-project, nexo, project, other)
|
|
31
31
|
decision: What was decided
|
|
32
32
|
alternatives: JSON array or text of options considered and why discarded
|
|
33
33
|
based_on: Data, metrics, or observations that informed this decision
|
|
@@ -35,7 +35,7 @@ def handle_decision_log(domain: str, decision: str, alternatives: str = '',
|
|
|
35
35
|
context_ref: Related followup/reminder ID (e.g., NF-ADS1, R71)
|
|
36
36
|
session_id: Current session ID (auto-filled if empty)
|
|
37
37
|
"""
|
|
38
|
-
valid_domains = {'ads', 'shopify', 'server', '
|
|
38
|
+
valid_domains = {'ads', 'shopify', 'server', 'my-project', 'nexo', 'project', 'other'}
|
|
39
39
|
if domain not in valid_domains:
|
|
40
40
|
return f"ERROR: domain debe ser uno de: {', '.join(sorted(valid_domains))}"
|
|
41
41
|
if confidence not in ('high', 'medium', 'low'):
|
|
@@ -92,10 +92,10 @@ def handle_decision_search(query: str = '', domain: str = '', days: int = 30) ->
|
|
|
92
92
|
|
|
93
93
|
Args:
|
|
94
94
|
query: Text to search in decision, alternatives, based_on, outcome
|
|
95
|
-
domain: Filter by area (ads, shopify, server,
|
|
95
|
+
domain: Filter by area (ads, shopify, server, my-project, nexo, project, other)
|
|
96
96
|
days: Look back N days (default 30)
|
|
97
97
|
"""
|
|
98
|
-
valid_domains = {'ads', 'shopify', 'server', '
|
|
98
|
+
valid_domains = {'ads', 'shopify', 'server', 'my-project', 'nexo', 'project', 'other'}
|
|
99
99
|
if domain and domain not in valid_domains:
|
|
100
100
|
return f"ERROR: domain debe ser uno de: {', '.join(sorted(valid_domains))}"
|
|
101
101
|
results = search_decisions(query, domain, days)
|
|
@@ -103,7 +103,7 @@ def handle_decision_search(query: str = '', domain: str = '', days: int = 30) ->
|
|
|
103
103
|
scope = f"'{query}'" if query else domain or 'todas'
|
|
104
104
|
return f"No decisions found for {scope} in {days} days."
|
|
105
105
|
|
|
106
|
-
lines = [f"
|
|
106
|
+
lines = [f"DECISIONS ({len(results)}):"]
|
|
107
107
|
for d in results:
|
|
108
108
|
conf = d.get('confidence', '?')
|
|
109
109
|
outcome_str = f" → {d['outcome'][:50]}" if d.get('outcome') else ""
|
|
@@ -265,7 +265,7 @@ def handle_change_log(files: str, what_changed: str, why: str,
|
|
|
265
265
|
triggered_by: str = '', affects: str = '',
|
|
266
266
|
risks: str = '', verify: str = '',
|
|
267
267
|
commit_ref: str = '', session_id: str = '') -> str:
|
|
268
|
-
"""Log a code/config change with full context.
|
|
268
|
+
"""Log a code/config change with full context. MANDATORY after every edit to production code.
|
|
269
269
|
|
|
270
270
|
Args:
|
|
271
271
|
files: File path(s) modified (comma-separated if multiple)
|
|
@@ -279,7 +279,7 @@ def handle_change_log(files: str, what_changed: str, why: str,
|
|
|
279
279
|
session_id: Current session ID
|
|
280
280
|
"""
|
|
281
281
|
if not files or not what_changed or not why:
|
|
282
|
-
return "ERROR: files, what_changed,
|
|
282
|
+
return "ERROR: files, what_changed, and why are required"
|
|
283
283
|
sid = session_id or 'unknown'
|
|
284
284
|
result = log_change(sid, files, what_changed, why, triggered_by, affects, risks, verify, commit_ref)
|
|
285
285
|
if "error" in result:
|
|
@@ -422,7 +422,7 @@ def handle_diary_archive_search(
|
|
|
422
422
|
|
|
423
423
|
Args:
|
|
424
424
|
query: Text to search in diary content
|
|
425
|
-
domain: Filter by project domain (e.g. '
|
|
425
|
+
domain: Filter by project domain (e.g. 'my-project', 'my-store')
|
|
426
426
|
year: Filter by year (e.g. 2026)
|
|
427
427
|
month: Filter by month (1-12), requires year
|
|
428
428
|
limit: Max results (default 20)
|
package/src/plugins/evolution.py
CHANGED
|
@@ -77,10 +77,10 @@ def handle_evolution_approve(log_id: int, notes: str = '') -> str:
|
|
|
77
77
|
|
|
78
78
|
Args:
|
|
79
79
|
log_id: Evolution log entry ID to approve
|
|
80
|
-
notes: Optional notes from
|
|
80
|
+
notes: Optional notes from the owner
|
|
81
81
|
"""
|
|
82
82
|
update_evolution_log_status(log_id, "accepted",
|
|
83
|
-
test_result=f"Approved by
|
|
83
|
+
test_result=f"Approved by the owner. {notes}".strip())
|
|
84
84
|
return f"Proposal #{log_id} APPROVED. Will be applied in next Evolution cycle."
|
|
85
85
|
|
|
86
86
|
|
|
@@ -92,7 +92,7 @@ def handle_evolution_reject(log_id: int, reason: str = '') -> str:
|
|
|
92
92
|
reason: Why this proposal was rejected
|
|
93
93
|
"""
|
|
94
94
|
update_evolution_log_status(log_id, "rejected",
|
|
95
|
-
test_result=f"Rejected: {reason}" if reason else "Rejected by
|
|
95
|
+
test_result=f"Rejected: {reason}" if reason else "Rejected by the owner")
|
|
96
96
|
return f"Proposal #{log_id} REJECTED. Reason: {reason or 'no reason given'}"
|
|
97
97
|
|
|
98
98
|
|
|
@@ -104,7 +104,7 @@ TOOLS = [
|
|
|
104
104
|
(handle_evolution_propose, "nexo_evolution_propose",
|
|
105
105
|
"Manually trigger an evolution analysis outside weekly schedule"),
|
|
106
106
|
(handle_evolution_approve, "nexo_evolution_approve",
|
|
107
|
-
"Approve a pending Evolution proposal (
|
|
107
|
+
"Approve a pending Evolution proposal (owner only)"),
|
|
108
108
|
(handle_evolution_reject, "nexo_evolution_reject",
|
|
109
109
|
"Reject a pending Evolution proposal with reason"),
|
|
110
110
|
]
|
package/src/plugins/guard.py
CHANGED
|
@@ -59,7 +59,7 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
|
|
|
59
59
|
|
|
60
60
|
Args:
|
|
61
61
|
files: Comma-separated file paths about to be edited
|
|
62
|
-
area: System area (
|
|
62
|
+
area: System area (my-project, shopify, infrastructure, nexo-ops, etc.)
|
|
63
63
|
include_schemas: Include DB table schemas if files touch database code (true/false)
|
|
64
64
|
"""
|
|
65
65
|
conn = get_db()
|
|
@@ -174,6 +174,23 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
|
|
|
174
174
|
"reason": "prohibition_keyword"
|
|
175
175
|
})
|
|
176
176
|
|
|
177
|
+
# 5b. Behavioral rules — when called without files (session-level check)
|
|
178
|
+
if not file_list:
|
|
179
|
+
behavioral = conn.execute(
|
|
180
|
+
"""SELECT l.id, l.title, l.category, COUNT(e.id) as violations
|
|
181
|
+
FROM learnings l
|
|
182
|
+
LEFT JOIN error_repetitions e ON e.original_learning_id = l.id
|
|
183
|
+
WHERE l.category = 'nexo-ops' AND l.status = 'active'
|
|
184
|
+
GROUP BY l.id
|
|
185
|
+
ORDER BY violations DESC, l.created_at DESC
|
|
186
|
+
LIMIT 5"""
|
|
187
|
+
).fetchall()
|
|
188
|
+
if behavioral:
|
|
189
|
+
result["behavioral_rules"] = [
|
|
190
|
+
{"id": r["id"], "rule": r["title"], "violations": r["violations"]}
|
|
191
|
+
for r in behavioral
|
|
192
|
+
]
|
|
193
|
+
|
|
177
194
|
# 6. Area repetition rate
|
|
178
195
|
if area:
|
|
179
196
|
total_area = conn.execute(
|
|
@@ -266,6 +283,13 @@ def handle_guard_check(files: str = "", area: str = "", include_schemas: str = "
|
|
|
266
283
|
lines.append(f" #{l['id']} [{l['category']}] {l['rule']}")
|
|
267
284
|
lines.append("")
|
|
268
285
|
|
|
286
|
+
if result.get("behavioral_rules"):
|
|
287
|
+
lines.append("SESSION BEHAVIORAL RULES (top 5 most-violated):")
|
|
288
|
+
for r in result["behavioral_rules"]:
|
|
289
|
+
v = f" ({r['violations']}x violated)" if r["violations"] > 0 else ""
|
|
290
|
+
lines.append(f" #{r['id']} {r['rule']}{v}")
|
|
291
|
+
lines.append("")
|
|
292
|
+
|
|
269
293
|
if result["universal_rules"]:
|
|
270
294
|
lines.append(f"UNIVERSAL RULES ({len(result['universal_rules'])}):")
|
|
271
295
|
for r in result["universal_rules"][:10]:
|
|
@@ -456,7 +480,7 @@ def handle_guard_cross_check(findings: list, area: str = "") -> str:
|
|
|
456
480
|
|
|
457
481
|
Args:
|
|
458
482
|
findings: List of audit finding strings to cross-check
|
|
459
|
-
area: System area to narrow the learning search (
|
|
483
|
+
area: System area to narrow the learning search (my-project, shopify, etc.)
|
|
460
484
|
"""
|
|
461
485
|
# Common English/Spanish stopwords to skip during keyword extraction
|
|
462
486
|
STOPWORDS = {
|
|
Binary file
|
|
Binary file
|
|
Binary file
|