@passioncode-ai/fabric-agent-adapter 0.34.0 → 0.35.0
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/CHANGELOG.md +61 -0
- package/README.md +4 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +48 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +40 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +67 -65
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +8 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +53 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +5 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +30 -11
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +178 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +33 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/__init__.py +135 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/plugin.yaml +5 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +45 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/openclaw.plugin.json +11 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/package.json +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +24 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/pi/fabric.mjs +36 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
3
|
+
"""Install bounded native context bridges. Dry-run by default; no model/service calls.
|
|
4
|
+
|
|
5
|
+
OpenCode/Pi load a managed shim. Hermes loads a managed plugin wrapper and needs
|
|
6
|
+
its native `hermes plugins enable fabric-agent-adapter` opt-in separately.
|
|
7
|
+
OpenClaw loads the bundled plugin through its native configuration. Removal
|
|
8
|
+
keeps unrelated settings and disables only this entry; existing allow/deny policy
|
|
9
|
+
is never loosened. JSON5 configuration is refused, not rewritten as guessed JSON.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import hashlib
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
BASE = Path(__file__).resolve().parent
|
|
19
|
+
spec = importlib.util.spec_from_file_location('_fabric_hook_install_common', BASE / 'fabric_session.py')
|
|
20
|
+
fs = importlib.util.module_from_spec(spec)
|
|
21
|
+
spec.loader.exec_module(fs)
|
|
22
|
+
MARKER = 'fabric-agent-adapter managed host hook v1'
|
|
23
|
+
IDENTITY = 'fabric-agent-adapter'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def destination(host, env):
|
|
27
|
+
home = fs._home(env)
|
|
28
|
+
if host == 'opencode':
|
|
29
|
+
return Path(env.get('XDG_CONFIG_HOME') or home / '.config') / 'opencode/plugins/fabric-agent-adapter.js'
|
|
30
|
+
if host == 'pi':
|
|
31
|
+
return Path(env.get('PI_CODING_AGENT_DIR') or home / '.pi/agent') / 'extensions/fabric-agent-adapter.js'
|
|
32
|
+
if host == 'hermes':
|
|
33
|
+
return Path(env.get('HERMES_HOME') or home / '.hermes') / 'plugins/fabric-agent-adapter'
|
|
34
|
+
return Path(env.get('OPENCLAW_CONFIG_PATH') or Path(env.get('OPENCLAW_STATE_DIR') or home / '.openclaw') / 'openclaw.json')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _object(parent, key):
|
|
38
|
+
if key not in parent: parent[key] = {}
|
|
39
|
+
if not isinstance(parent[key], dict): raise fs.Refused('unexpected object shape at ' + key)
|
|
40
|
+
return parent[key]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _list(parent, key):
|
|
44
|
+
if key not in parent: parent[key] = []
|
|
45
|
+
if not isinstance(parent[key], list) or not all(isinstance(x, str) for x in parent[key]):
|
|
46
|
+
raise fs.Refused('unexpected string-list shape at ' + key)
|
|
47
|
+
return parent[key]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def openclaw_edit(data, script, remove):
|
|
51
|
+
try:
|
|
52
|
+
doc = json.loads(data.decode('utf-8')) if data else {}
|
|
53
|
+
except (ValueError, UnicodeError):
|
|
54
|
+
raise fs.Refused('OpenClaw config is not strict JSON; use the native config CLI for JSON5')
|
|
55
|
+
if not isinstance(doc, dict): raise fs.Refused('OpenClaw config must be an object')
|
|
56
|
+
target = str(script.parent / 'hosts/openclaw')
|
|
57
|
+
def ours(value):
|
|
58
|
+
if value == target:
|
|
59
|
+
return True
|
|
60
|
+
if not value.replace('\\', '/').endswith('/working-with-fabric/scripts/hosts/openclaw'):
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
manifest = fs.read_small(Path(value) / 'openclaw.plugin.json')
|
|
64
|
+
return isinstance(manifest, str) and json.loads(manifest).get('id') == IDENTITY
|
|
65
|
+
except (ValueError, AttributeError):
|
|
66
|
+
return False
|
|
67
|
+
if remove:
|
|
68
|
+
existing = doc.get('plugins', {})
|
|
69
|
+
if isinstance(existing, dict):
|
|
70
|
+
existing_entries = existing.get('entries', {})
|
|
71
|
+
existing_load = existing.get('load', {})
|
|
72
|
+
existing_paths = existing_load.get('paths', []) if isinstance(existing_load, dict) else []
|
|
73
|
+
has_entry = isinstance(existing_entries, dict) and IDENTITY in existing_entries
|
|
74
|
+
has_path = isinstance(existing_paths, list) and any(isinstance(p, str) and ours(p) for p in existing_paths)
|
|
75
|
+
if not has_entry and not has_path:
|
|
76
|
+
return data
|
|
77
|
+
plugins = _object(doc, 'plugins')
|
|
78
|
+
if not remove and (plugins.get('enabled') is False or IDENTITY in _list(plugins, 'deny')):
|
|
79
|
+
raise fs.Refused('existing OpenClaw plugin policy disables this adapter; policy was preserved')
|
|
80
|
+
entries = _object(plugins, 'entries')
|
|
81
|
+
entry = _object(entries, IDENTITY)
|
|
82
|
+
hooks = _object(entry, 'hooks')
|
|
83
|
+
if not remove and (entry.get('enabled') is False or any(hooks.get(k) is False for k in ('allowConversationAccess','allowPromptInjection'))):
|
|
84
|
+
raise fs.Refused('existing OpenClaw entry denies this hook; policy was preserved')
|
|
85
|
+
load = _object(plugins, 'load')
|
|
86
|
+
paths = _list(load, 'paths')
|
|
87
|
+
kept = [p for p in paths if not ours(p)]
|
|
88
|
+
load['paths'] = kept if remove else kept + [target]
|
|
89
|
+
entry['enabled'] = not remove
|
|
90
|
+
if remove and set(entry) <= {'enabled','hooks'} and hooks == {'allowConversationAccess':True,'allowPromptInjection':True}:
|
|
91
|
+
del entries[IDENTITY]
|
|
92
|
+
if not remove:
|
|
93
|
+
hooks.update(allowConversationAccess=True, allowPromptInjection=True)
|
|
94
|
+
if 'allow' in plugins:
|
|
95
|
+
allow = _list(plugins, 'allow')
|
|
96
|
+
if IDENTITY not in allow: allow.append(IDENTITY)
|
|
97
|
+
return (json.dumps(doc, indent=2) + '\n').encode()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def plan(host, path, script, remove):
|
|
101
|
+
"""Return [(path, expected bytes, desired bytes|None)] before any mutation."""
|
|
102
|
+
if host == 'openclaw':
|
|
103
|
+
old = fs._read_bytes(path)
|
|
104
|
+
if remove and old is None: return []
|
|
105
|
+
return [(path, old, openclaw_edit(old, script, remove))]
|
|
106
|
+
if host == 'hermes':
|
|
107
|
+
if path.is_symlink(): raise fs.Refused('plugin directory is a symlink; nothing was changed')
|
|
108
|
+
module = str(script.parent / 'hosts/hermes/__init__.py')
|
|
109
|
+
loader = ('# ' + MARKER + '\nimport importlib.util\n'
|
|
110
|
+
'_spec = importlib.util.spec_from_file_location("_fabric_native_hermes", ' + repr(module) + ')\n'
|
|
111
|
+
'_module = importlib.util.module_from_spec(_spec)\n_spec.loader.exec_module(_module)\nregister = _module.register\n')
|
|
112
|
+
manifest = '# ' + MARKER + '\n' + (script.parent / 'hosts/hermes/plugin.yaml').read_text()
|
|
113
|
+
desired = {path/'__init__.py':loader, path/'plugin.yaml':manifest}
|
|
114
|
+
else:
|
|
115
|
+
module = (script.parent / ('hosts/' + host + '/fabric.mjs')).as_uri()
|
|
116
|
+
desired = {path: '// ' + MARKER + '\nexport { default } from ' + json.dumps(module) + ';\n'}
|
|
117
|
+
changes = []
|
|
118
|
+
for target, text in desired.items():
|
|
119
|
+
old = fs._read_bytes(target)
|
|
120
|
+
if old is not None and MARKER.encode() not in old.split(b'\n', 1)[0]:
|
|
121
|
+
raise fs.Refused('destination is not an adapter-managed file: ' + str(target))
|
|
122
|
+
changes.append((target, old, None if remove else text.encode()))
|
|
123
|
+
return changes
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def plan_digest(changes):
|
|
127
|
+
return hashlib.sha256(json.dumps([(str(p),fs._digest(old)) for p,old,_ in changes]).encode()).hexdigest()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv=None, env=None, out=print, err=print):
|
|
131
|
+
env = os.environ if env is None else env
|
|
132
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
133
|
+
parser.add_argument('--host', choices=['hermes','opencode','openclaw','pi'], required=True)
|
|
134
|
+
mode=parser.add_mutually_exclusive_group()
|
|
135
|
+
mode.add_argument('--apply',action='store_true');mode.add_argument('--remove',action='store_true');mode.add_argument('--dry-run',action='store_true')
|
|
136
|
+
parser.add_argument('--config');parser.add_argument('--script');parser.add_argument('--expect-sha256')
|
|
137
|
+
args=parser.parse_args(argv)
|
|
138
|
+
try:
|
|
139
|
+
script=Path(args.script).expanduser().absolute() if args.script else BASE/'fabric_session.py'
|
|
140
|
+
if not script.is_file(): raise fs.Refused('shared helper is missing')
|
|
141
|
+
if not args.remove:
|
|
142
|
+
required = {'hermes':['hosts/hermes/__init__.py','hosts/hermes/plugin.yaml'],
|
|
143
|
+
'opencode':['hosts/context.mjs','hosts/opencode/fabric.mjs'],
|
|
144
|
+
'pi':['hosts/context.mjs','hosts/pi/fabric.mjs'],
|
|
145
|
+
'openclaw':['hosts/context.mjs','hosts/openclaw/index.mjs','hosts/openclaw/package.json','hosts/openclaw/openclaw.plugin.json']}[args.host]
|
|
146
|
+
if any(not (script.parent/p).is_file() for p in required):
|
|
147
|
+
raise fs.Refused('native adapter source is missing; update the complete skill before installing hooks')
|
|
148
|
+
path=Path(args.config).expanduser().absolute() if args.config else destination(args.host,env)
|
|
149
|
+
changes=plan(args.host,path,script,args.remove)
|
|
150
|
+
digest=plan_digest(changes)
|
|
151
|
+
if args.expect_sha256 and digest != args.expect_sha256: raise fs.Refused('configuration changed since the plan')
|
|
152
|
+
out('host=%s sha256=%s action=%s' % (args.host,digest,'remove' if args.remove else 'install'))
|
|
153
|
+
for p,old,new in changes: out('%s %s' % ('unchanged' if old==new else 'would change',p))
|
|
154
|
+
if args.apply or args.remove:
|
|
155
|
+
# Preflight every destination before changing even the first one.
|
|
156
|
+
if any(fs._read_bytes(p) != old for p,old,_ in changes): raise fs.Refused('destination changed during planning')
|
|
157
|
+
for p,old,new in changes:
|
|
158
|
+
if old==new: continue
|
|
159
|
+
if new is None:
|
|
160
|
+
if fs._read_bytes(p) != old: raise fs.Refused('destination changed during removal')
|
|
161
|
+
if old is not None: p.unlink()
|
|
162
|
+
else: fs._replace_file(p,new,old)
|
|
163
|
+
if args.host=='hermes' and args.remove:
|
|
164
|
+
# A foreign addition in this directory is preserved.
|
|
165
|
+
try:path.rmdir()
|
|
166
|
+
except OSError:pass
|
|
167
|
+
out('native loader files updated; existing sessions must reload to use them')
|
|
168
|
+
else: out('dry run; no files changed. Apply with --apply --expect-sha256 ' + digest)
|
|
169
|
+
if args.host=='hermes':
|
|
170
|
+
out('native activation: hermes plugins %s fabric-agent-adapter' % ('disable' if args.remove else 'enable'))
|
|
171
|
+
return 0
|
|
172
|
+
except (fs.Refused,OSError,ValueError) as exc:
|
|
173
|
+
# Refused has only our shape diagnostics; parser failures never echo config bytes.
|
|
174
|
+
err('host-hooks: refused: ' + (str(exc) if isinstance(exc,fs.Refused) else type(exc).__name__))
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
if __name__=='__main__':raise SystemExit(main())
|
|
178
|
+
# #endregion native-host-context
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
2
|
+
// Shared, non-resident bridge. A context hook never enrolls or calls a model.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const scriptPath = fileURLToPath(new URL('../fabric_session.py', import.meta.url));
|
|
7
|
+
const hosts = new Set(['opencode', 'pi', 'openclaw']);
|
|
8
|
+
const cap = 64 * 1024;
|
|
9
|
+
|
|
10
|
+
export async function readContext(host, cwd, options = {}) {
|
|
11
|
+
if (!hosts.has(host) || typeof cwd !== 'string' || !cwd || cwd.includes('\0')) return '';
|
|
12
|
+
const payload = {cwd};
|
|
13
|
+
if (options.fabricRegistered === true) payload.fabric_registered = true;
|
|
14
|
+
const input = JSON.stringify(payload);
|
|
15
|
+
if (Buffer.byteLength(input) > cap) return '';
|
|
16
|
+
const timeout = Number.isFinite(options.timeoutMs)
|
|
17
|
+
? Math.max(1, Math.min(4000, options.timeoutMs)) : 4000;
|
|
18
|
+
return new Promise(resolve => {
|
|
19
|
+
try {
|
|
20
|
+
const child = execFile(options.python || 'python3',
|
|
21
|
+
['-S', options.script || scriptPath, 'hook', 'session-start', '--host', host],
|
|
22
|
+
{timeout, killSignal:'SIGKILL', maxBuffer:cap, encoding:'utf8', windowsHide:true},
|
|
23
|
+
(error, stdout) => {
|
|
24
|
+
if (error || typeof stdout !== 'string' || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(stdout)) return resolve('');
|
|
25
|
+
resolve(stdout.trim());
|
|
26
|
+
});
|
|
27
|
+
// A missing executable or early exit may close stdin before it is written.
|
|
28
|
+
child.stdin?.on('error', () => {});
|
|
29
|
+
child.stdin?.end(input);
|
|
30
|
+
} catch { resolve(''); }
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
// #endregion native-host-context
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/__init__.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
2
|
+
"""Hermes native, session-frozen Fabric context; no model or lifecycle worker."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import signal
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
MAX_CONTEXT_CHARS = 4000
|
|
14
|
+
TIMEOUT_SECONDS = 4
|
|
15
|
+
MAX_OUTPUT_BYTES = 16000
|
|
16
|
+
MAX_INPUT_BYTES = 4096
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _kill_owned(process):
|
|
20
|
+
try:
|
|
21
|
+
if os.name == "posix":
|
|
22
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
23
|
+
else:
|
|
24
|
+
process.kill()
|
|
25
|
+
except (OSError, ProcessLookupError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _capture(command, payload):
|
|
30
|
+
"""Bound output while reading, not after buffering a noisy helper's entire stdout."""
|
|
31
|
+
if len(payload) > MAX_INPUT_BYTES:
|
|
32
|
+
return None
|
|
33
|
+
process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
34
|
+
stderr=subprocess.DEVNULL, start_new_session=os.name == "posix")
|
|
35
|
+
output = bytearray()
|
|
36
|
+
invalid = threading.Event()
|
|
37
|
+
deadline = time.monotonic() + TIMEOUT_SECONDS
|
|
38
|
+
|
|
39
|
+
def read_output():
|
|
40
|
+
try:
|
|
41
|
+
while True:
|
|
42
|
+
chunk = process.stdout.read(min(1024, MAX_OUTPUT_BYTES + 1 - len(output)))
|
|
43
|
+
if not chunk:
|
|
44
|
+
return
|
|
45
|
+
output.extend(chunk)
|
|
46
|
+
if len(output) > MAX_OUTPUT_BYTES:
|
|
47
|
+
invalid.set()
|
|
48
|
+
_kill_owned(process)
|
|
49
|
+
return
|
|
50
|
+
except (OSError, ValueError):
|
|
51
|
+
invalid.set()
|
|
52
|
+
|
|
53
|
+
reader = threading.Thread(target=read_output, daemon=True)
|
|
54
|
+
reader.start()
|
|
55
|
+
try:
|
|
56
|
+
process.stdin.write(payload)
|
|
57
|
+
process.stdin.close()
|
|
58
|
+
process.wait(timeout=max(0.001, deadline - time.monotonic()))
|
|
59
|
+
reader.join(timeout=max(0, deadline - time.monotonic()))
|
|
60
|
+
if reader.is_alive() or invalid.is_set() or process.returncode != 0:
|
|
61
|
+
return None
|
|
62
|
+
return bytes(output)
|
|
63
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
64
|
+
return None
|
|
65
|
+
finally:
|
|
66
|
+
# Own process group only: also close a pipe inherited by an owned child.
|
|
67
|
+
_kill_owned(process)
|
|
68
|
+
process.wait()
|
|
69
|
+
reader.join(timeout=0.2)
|
|
70
|
+
if not reader.is_alive():
|
|
71
|
+
process.stdout.close()
|
|
72
|
+
process.stdin.close()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fabric_registered(config=None):
|
|
76
|
+
"""Read only the native registration flag; never forward server config or credentials."""
|
|
77
|
+
try:
|
|
78
|
+
if config is None:
|
|
79
|
+
from hermes_cli.config import load_config_readonly
|
|
80
|
+
config = load_config_readonly()
|
|
81
|
+
servers = config.get("mcp_servers") if isinstance(config, Mapping) else None
|
|
82
|
+
server = servers.get("fabric") if isinstance(servers, Mapping) else None
|
|
83
|
+
if not isinstance(server, Mapping):
|
|
84
|
+
return False
|
|
85
|
+
enabled = server.get("enabled", True)
|
|
86
|
+
return enabled is True or isinstance(enabled, str) and enabled.lower().strip() in ("true", "1", "yes", "on")
|
|
87
|
+
except (ImportError, OSError, TypeError, ValueError):
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def context_for_session(session_info, session_script=None):
|
|
92
|
+
"""Read the shared preamble through its bounded plain-text host protocol."""
|
|
93
|
+
if not isinstance(session_info, Mapping):
|
|
94
|
+
return ""
|
|
95
|
+
cwd = session_info.get("cwd", "")
|
|
96
|
+
if not isinstance(cwd, str) or len(cwd) > MAX_INPUT_BYTES // 2 or "\x00" in cwd:
|
|
97
|
+
return ""
|
|
98
|
+
try:
|
|
99
|
+
if session_script is None:
|
|
100
|
+
script = Path(__file__).resolve().parents[2] / "fabric_session.py"
|
|
101
|
+
elif isinstance(session_script, str) and Path(session_script).is_absolute():
|
|
102
|
+
script = Path(session_script)
|
|
103
|
+
else:
|
|
104
|
+
return ""
|
|
105
|
+
if not script.is_file():
|
|
106
|
+
return ""
|
|
107
|
+
result = _capture(
|
|
108
|
+
[sys.executable, "-S", str(script), "hook", "session-start", "--host", "hermes"],
|
|
109
|
+
json.dumps({"cwd": cwd or os.getcwd(), "fabric_registered": fabric_registered()},
|
|
110
|
+
ensure_ascii=False).encode("utf-8"),
|
|
111
|
+
)
|
|
112
|
+
if result is None:
|
|
113
|
+
return ""
|
|
114
|
+
text = result.decode("utf-8").strip()
|
|
115
|
+
if len(text) > MAX_CONTEXT_CHARS or any(ord(char) < 32 and char not in "\n\r\t" for char in text):
|
|
116
|
+
return ""
|
|
117
|
+
return text
|
|
118
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
119
|
+
# Context is best effort; never leak configuration, diagnostics or hook input.
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def register(ctx):
|
|
124
|
+
"""Use Hermes' cache-safe prompt section API, not observer hook returns."""
|
|
125
|
+
if not callable(getattr(ctx, "register_system_prompt_section", None)):
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
script = ctx.get_config("session_script", None)
|
|
129
|
+
except (AttributeError, OSError, TypeError, ValueError):
|
|
130
|
+
return
|
|
131
|
+
ctx.register_system_prompt_section(
|
|
132
|
+
"fabric.context", lambda info: context_for_session(info, script),
|
|
133
|
+
position="after_memory", max_chars=MAX_CONTEXT_CHARS,
|
|
134
|
+
)
|
|
135
|
+
# #endregion native-host-context
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
2
|
+
// Native OpenClaw prompt hook. Never observes conversation content or sends messages.
|
|
3
|
+
import { readContext } from '../context.mjs';
|
|
4
|
+
|
|
5
|
+
function fabricRegistered(config) {
|
|
6
|
+
const servers = config?.mcp?.servers;
|
|
7
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return false;
|
|
8
|
+
return ['fabric', 'fabric-mcp'].some(name => {
|
|
9
|
+
const entry = Object.hasOwn(servers, name) ? servers[name] : undefined;
|
|
10
|
+
return entry && typeof entry === 'object' && !Array.isArray(entry)
|
|
11
|
+
&& entry.enabled !== false
|
|
12
|
+
&& [entry.command, entry.url].some(value => typeof value === 'string' && value.trim());
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createPlugin(contextReader = readContext) {
|
|
17
|
+
return {
|
|
18
|
+
id: 'fabric-agent-adapter',
|
|
19
|
+
name: 'Fabric Agent Adapter',
|
|
20
|
+
description: 'Adds bounded Fabric context through the native prompt hook.',
|
|
21
|
+
register(api) {
|
|
22
|
+
api.on('before_prompt_build', async (_event, ctx) => {
|
|
23
|
+
// workspaceDir is optional in the host contract. Do not substitute the
|
|
24
|
+
// Gateway process directory, which can belong to a different project.
|
|
25
|
+
if (typeof ctx?.workspaceDir !== 'string' || !ctx.workspaceDir.trim()) return;
|
|
26
|
+
try {
|
|
27
|
+
// Configured is not connected/admitted. Forward only the boolean,
|
|
28
|
+
// never server configuration, headers, environment or credentials.
|
|
29
|
+
const options = fabricRegistered(api.config) ? { fabricRegistered: true } : {};
|
|
30
|
+
const context = await contextReader('openclaw', ctx.workspaceDir, options);
|
|
31
|
+
ctx.hookInvocation?.assertActive();
|
|
32
|
+
if (typeof context !== 'string' || !context.trim()) return;
|
|
33
|
+
return { prependContext: context };
|
|
34
|
+
} catch {
|
|
35
|
+
// Missing dependencies, unavailable context and expired invocations
|
|
36
|
+
// must not stop an unrelated turn or leak diagnostics into its prompt.
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default createPlugin();
|
|
45
|
+
// #endregion native-host-context
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "fabric-agent-adapter",
|
|
3
|
+
"name": "Fabric Agent Adapter",
|
|
4
|
+
"description": "Bounded Fabric context using the native OpenClaw prompt hook.",
|
|
5
|
+
"activation": { "onStartup": true },
|
|
6
|
+
"configSchema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {}
|
|
10
|
+
}
|
|
11
|
+
}
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/package.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@passioncode-ai/fabric-agent-adapter-openclaw",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial",
|
|
6
|
+
"openclaw": {
|
|
7
|
+
"extensions": ["./index.mjs"],
|
|
8
|
+
"compat": { "pluginApi": ">=2026.10.4" }
|
|
9
|
+
}
|
|
10
|
+
}
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
2
|
+
import { readContext } from '../context.mjs';
|
|
3
|
+
|
|
4
|
+
const marker = '[fabric-agent-adapter]';
|
|
5
|
+
|
|
6
|
+
// OpenCode's legacy loader treats every export as a plugin factory.
|
|
7
|
+
export default async function fabricPlugin({directory} = {}) {
|
|
8
|
+
let fabricRegistered = false;
|
|
9
|
+
return {
|
|
10
|
+
config: async config => {
|
|
11
|
+
const entry = config?.mcp?.fabric;
|
|
12
|
+
// Observe the host's resolved configuration; never read or forward secrets.
|
|
13
|
+
fabricRegistered = !!entry && typeof entry === 'object' && entry.enabled !== false &&
|
|
14
|
+
((entry.type === 'local' && Array.isArray(entry.command) && entry.command.length > 0) ||
|
|
15
|
+
(entry.type === 'remote' && typeof entry.url === 'string' && entry.url.length > 0));
|
|
16
|
+
},
|
|
17
|
+
'experimental.chat.system.transform': async (_input, output) => {
|
|
18
|
+
if (!Array.isArray(output?.system) || output.system.some(part => typeof part === 'string' && part.includes(marker))) return;
|
|
19
|
+
const context = await readContext('opencode', directory, {fabricRegistered});
|
|
20
|
+
if (context && !output.system.includes(context)) output.system.push(context);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
// #endregion native-host-context
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
2
|
+
import { readContext } from '../context.mjs';
|
|
3
|
+
|
|
4
|
+
const marker = '[fabric-agent-adapter]';
|
|
5
|
+
|
|
6
|
+
export default function fabricExtension(pi) {
|
|
7
|
+
pi.on('before_agent_start', async (event, ctx) => {
|
|
8
|
+
if (!event || !ctx) return;
|
|
9
|
+
const options = event.systemPromptOptions;
|
|
10
|
+
if (typeof options?.forceSystemPrompt === 'string' && options.forceSystemPrompt.includes(marker)) return;
|
|
11
|
+
if (!options && typeof event.systemPrompt === 'string' && event.systemPrompt.includes(marker)) return;
|
|
12
|
+
if (options?.sections?.fabric_agent_adapter) return;
|
|
13
|
+
let fabricRegistered = false;
|
|
14
|
+
try {
|
|
15
|
+
const tools = pi.getAllTools?.();
|
|
16
|
+
fabricRegistered = Array.isArray(tools) && tools.some(tool =>
|
|
17
|
+
tool?.exposure !== 'hidden' &&
|
|
18
|
+
(tool?.name === 'fabric_whoami' || tool?.name === 'mcp__fabric__fabric_whoami'));
|
|
19
|
+
} catch { /* Early startup or a disabled tool registry is not registration. */ }
|
|
20
|
+
const context = await readContext('pi', ctx.cwd, {fabricRegistered});
|
|
21
|
+
if (!context) return;
|
|
22
|
+
if (typeof options?.forceSystemPrompt === 'string') {
|
|
23
|
+
if (options.forceSystemPrompt.includes(context)) return;
|
|
24
|
+
options.forceSystemPrompt += '\n\n' + context;
|
|
25
|
+
} else if (options && typeof options === 'object') {
|
|
26
|
+
options.sections ??= {};
|
|
27
|
+
if (typeof options.sections !== 'object' || Array.isArray(options.sections)) return;
|
|
28
|
+
options.sections.fabric_agent_adapter = context;
|
|
29
|
+
} else if (typeof event.systemPrompt === 'string') {
|
|
30
|
+
if (event.systemPrompt.includes(context)) return;
|
|
31
|
+
// Older Pi versions expose only the replacement-prompt return contract.
|
|
32
|
+
return {systemPrompt:event.systemPrompt + '\n\n' + context};
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// #endregion native-host-context
|