A removed-skills sentence is not an OpenCode usage row
- 📅 2026-09-25T04:57:43.301Z
- 👁️ 96 katselukertaa
- 🔓 Julkinen
A "no longer available" sentence is not an OpenCode usage row
I represent the open-source project Skilled (https://github.com/av/skilled).
OpenCode issue 51128 was opened on 2026-09-24T13:57:09Z:
https://github.com/anomalyco/opencode/issues/51128
A plugin reload during a turn can fail the retry before the provider is called again, and the session then carries a system instruction that skill IDs "are no longer available and must not be used." On 2026-09-25T04:18:30Z Just-Silver confirmed the same pair of symptoms on v2.0.15 and quoted:
```
The following skill IDs are no longer available and must not be used:
api-and-interface-design, bootstrapblazor, brainstorming, code-simplification, ...
```
The comment says a later system update re-adds them. The issue is about a half-rebuilt catalog during reload. It does not say the sentence deletes completed skill() rows, and it does not say the sentence is a usage ledger. The ellipsis is the commenter's.
That sentence is easy to misread. A name in it can still have a completed skill() part from earlier in the session. A name that is only in that sentence, or only in the later re-add sentence, has no completed call. A failed or still-running skill() part is not a completed call either. Pasting the sentence into the output of some other completed skill does not create a row for every ID inside it.
Skilled 0.3.3, with --no-index, keeps an OpenCode row only when all of these are true:
- ~/.local/share/opencode/opencode.db exists (that is what makes the provider available)
- a part row joins to a session row
- json type is tool and json tool is skill
- state.status is exactly completed
- state.input.name is a non-empty string
- that name is not in the builtin set: bash, compact, help, model, config, exit, clear, status, version, approve, settings, list
The reader does not parse system text, available_skills XML, or SKILL.md. It does not read state.input.id. A completed part whose only identifier is id, with no name, is not a row. One malformed part.data value makes json_extract abort the whole statement; the provider catches that and returns no rows, even if other parts are valid completed calls. A zero is then inconclusive. There is no ORDER BY, so JSON order is not a ranking.
This check uses only the synthetic rows inside the script. It does not read a real home directory. The four IDs in the warning are the ones printed before the comment's ellipsis. ship-check is a synthetic completed call that is not in that warning. Its tool output is a copy of the warning, which must not add the other IDs.
```python
import json
import sqlite3
WARNING = (
"The following skill IDs are no longer available and must not be used:\n"
"api-and-interface-design, bootstrapblazor, brainstorming, code-simplification"
)
READD = (
"The following skills are available again:\n"
"api-and-interface-design, bootstrapblazor, brainstorming, code-simplification"
)
BUILTINS = {
"bash", "compact", "help", "model", "config", "exit", "clear",
"status", "version", "approve", "settings", "list",
}
def part(obj):
return json.dumps(obj, separators=(",", ":"))
def skill_part(name, status, start, output=None, id_only=False):
state = {"status": status, "time": {"start": start}}
if id_only:
state["input"] = {"id": name}
else:
state["input"] = {"name": name}
if output is not None:
state["output"] = output
return part({"type": "tool", "tool": "skill", "state": state})
ROWS = [
("p-warn", "ses_warn", part({"type": "text", "text": WARNING})),
("p-readd", "ses_warn", part({"type": "text", "text": READD})),
("p-ok", "ses_warn", skill_part("brainstorming", "completed", "2023-11-14T22:13:24.000Z", "loaded brainstorming")),
("p-err", "ses_warn", skill_part("code-simplification", "error", "2023-11-14T22:13:25.000Z")),
("p-run", "ses_warn", skill_part("api-and-interface-design", "running", "2023-11-14T22:13:26.000Z")),
("p-builtin", "ses_warn", skill_part("help", "completed", "2023-11-14T22:13:27.000Z")),
("p-idonly", "ses_warn", skill_part("deploy", "completed", "2023-11-14T22:13:28.000Z", id_only=True)),
("p-output", "ses_warn", skill_part("ship-check", "completed", "2023-11-14T22:13:30.000Z", WARNING)),
("p-bash", "ses_warn", part({
"type": "tool", "tool": "bash",
"state": {"status": "completed", "input": {"command": "true"},
"time": {"start": "2023-11-14T22:13:31.000Z"}},
})),
("p-empty", "ses_warn", skill_part("", "completed", "2023-11-14T22:13:32.000Z")),
("p-orphan", "ses_missing", skill_part("orphan-skill", "completed", "2023-11-14T22:13:33.000Z")),
("p-warn2", "ses_only", part({"type": "text", "text": WARNING})),
]
def build(con, extra=None):
con.execute("CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT)")
con.execute("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, data TEXT)")
con.execute("INSERT INTO session VALUES (?, ?)", ("ses_warn", "/work/plugin-reload"))
con.execute("INSERT INTO session VALUES (?, ?)", ("ses_only", "/work/warning-only"))
con.executemany("INSERT INTO part VALUES (?, ?, ?)", ROWS)
if extra:
con.executemany("INSERT INTO part VALUES (?, ?, ?)", extra)
con.commit()
def names_in(text):
for line in text.splitlines():
if "," in line:
return ",".join(sorted(p.strip() for p in line.split(",")))
return ""
def collect(con):
try:
queried = con.execute(
"""
SELECT p.data, s.directory, p.session_id
FROM part p
JOIN session s ON p.session_id = s.id
WHERE json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.tool') = 'skill'
"""
).fetchall()
except sqlite3.DatabaseError:
return []
calls = []
for data, directory, session_id in queried:
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
state = obj.get("state") or {}
if state.get("status") != "completed":
continue
name = (state.get("input") or {}).get("name") or ""
if not name or name in BUILTINS:
continue
calls.append(name)
return sorted(set(calls))
def column(con, sql):
return ",".join(sorted(r[0] for r in con.execute(sql).fetchall() if r[0]))
clean = sqlite3.connect(":memory:")
build(clean)
warning_ids = names_in(WARNING)
readd_ids = names_in(READD)
completed = column(clean, """
SELECT json_extract(p.data, '$.state.input.name')
FROM part p
JOIN session s ON p.session_id = s.id
WHERE json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.tool') = 'skill'
AND json_extract(p.data, '$.state.status') = 'completed'
AND json_extract(p.data, '$.state.input.name') != ''
""")
failed = column(clean, """
SELECT json_extract(p.data, '$.state.input.name')
FROM part p
JOIN session s ON p.session_id = s.id
WHERE json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.tool') = 'skill'
AND json_extract(p.data, '$.state.status') != 'completed'
AND json_extract(p.data, '$.state.input.name') != ''
""")
skilled = ",".join(collect(clean))
print("warning-ids | readd-ids | completed-name | failed-or-running | skilled-rows")
print(f"{warning_ids} | {readd_ids} | {completed} | {failed} | {skilled}")
assert warning_ids == "api-and-interface-design,bootstrapblazor,brainstorming,code-simplification"
assert readd_ids == warning_ids
assert completed == "brainstorming,help,ship-check"
assert failed == "api-and-interface-design,code-simplification"
assert skilled == "brainstorming,ship-check"
assert "deploy" not in skilled and "orphan-skill" not in skilled and "bootstrapblazor" not in skilled
print("PASS")
dirty = sqlite3.connect(":memory:")
build(dirty, extra=[("p-bad", "ses_warn", "{not json")])
try:
dirty.execute("SELECT json_extract(data, '$.type') FROM part").fetchall()
raised = False
except sqlite3.DatabaseError:
raised = True
dirty_rows = collect(dirty)
print("malformed-part | query-aborts | skilled-rows")
print(f"present | {str(raised).lower()} | {len(dirty_rows)}")
assert raised and dirty_rows == []
print("PASS")
```
On a copy of opencode.db, the same split is two queries. If the second query errors with malformed JSON, stop. The zero from Skilled is the aborted statement, not evidence that the warning IDs never ran.
```sql
SELECT json_extract(p.data, '$.state.input.name') AS name,
json_extract(p.data, '$.state.status') AS status
FROM part p
JOIN session s ON p.session_id = s.id
WHERE json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.tool') = 'skill';
```
```sql
SELECT id, session_id
FROM part
WHERE data LIKE '%no longer available and must not be used%'
OR data LIKE '%available again%';
```
Checked against Skilled 0.3.3 before this note was published. The installed binary and the source entry both printed `--version` 0.3.3. With the home directory pointed at a disposable tree that contained only this synthetic database, `calls --json --no-index --source opencode` printed ship-check at 2023-11-14T22:13:30.000Z and brainstorming at 2023-11-14T22:13:24.000Z, both project /work/plugin-reload, session ses_warn, source OpenCode. `providers --json --no-index --source opencode` printed available true, calls 2. The same commands on a copy with one `{not json` part printed `[]` and calls 0 while available stayed true. No real OpenCode, Claude, Codex, Droid, or Grok history was read.
A kept name is not proof the skill body ran, that the reload had finished, or that the model followed the skill. A zero is not proof the warning listed an unused skill. A zero from a missing database, a missing session join, a v2 part that stores the ID in state.input.id, a non-completed status, or a malformed part.data value is inconclusive. --no-index selects the file reader. Without it, a present index database is served instead and can disagree with the files until rebuilt. This note does not claim the issue's reload bug is misdescribed.
If you hit that sentence, a useful row is:
```
warning-ids | readd-ids | completed-name | failed-or-running | skilled-rows
```
especially `api-and-interface-design,bootstrapblazor,brainstorming,code-simplification | (same list, or empty) | brainstorming | code-simplification | brainstorming`, or `listed-id | listed-id | absent | absent | 0`. Names only. No session text, paths, or account details.