conceptkernel 1.0.0__py3-none-any.whl

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.
cklib/ledger.py ADDED
@@ -0,0 +1,216 @@
1
+ """Kernel-wide action ledger — append-only JSONL action log.
2
+
3
+ Extracted from LOCAL.ClaudeCode's ``log_action()`` pattern
4
+ (ref-tg-cli/concepts/LOCAL.ClaudeCode/tool/processor.py lines 79-134).
5
+
6
+ Each kernel invocation writes one JSONL entry to a daily file under
7
+ ``storage/ledger/actions-{YYYY-MM-DD}.jsonl``. Files are never rewritten —
8
+ entries are always appended.
9
+
10
+ Usage:
11
+ from cklib.ledger import log_action, read_ledger
12
+
13
+ log_action(
14
+ ck_dir="concepts/MyKernel",
15
+ action_name="task.create",
16
+ status="completed",
17
+ task_id="T001",
18
+ goal_id="G001",
19
+ detail={"items": 3},
20
+ )
21
+
22
+ entries = read_ledger("concepts/MyKernel", limit=20)
23
+ """
24
+
25
+ import json
26
+ import os
27
+ import threading
28
+ import time as _time
29
+ from datetime import datetime, timezone
30
+ from glob import glob
31
+
32
+ import yaml
33
+
34
+ __all__ = ["log_action", "read_ledger"]
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Internal: resolve kernel identity from conceptkernel.yaml
38
+ # ---------------------------------------------------------------------------
39
+
40
+ # File-level lock — one per process, protects the open-write-flush sequence.
41
+ _write_lock = threading.Lock()
42
+
43
+
44
+ def _resolve_kernel(ck_dir: str) -> tuple[str, str, str]:
45
+ """Return (kernel_name, kernel_urn, kernel_version) from conceptkernel.yaml.
46
+
47
+ Falls back to the directory basename if the YAML is absent or malformed.
48
+ """
49
+ ck_yaml = os.path.join(ck_dir, "conceptkernel.yaml")
50
+ if os.path.isfile(ck_yaml):
51
+ try:
52
+ with open(ck_yaml) as f:
53
+ data = yaml.safe_load(f)
54
+ meta = data.get("metadata", {})
55
+ name = meta.get("name", os.path.basename(os.path.abspath(ck_dir)))
56
+ urn = meta.get("urn", "")
57
+ version = meta.get("version", "v1.0")
58
+ if not urn:
59
+ urn = "ckp://Kernel#%s:%s" % (name, version)
60
+ return name, urn, version
61
+ except Exception:
62
+ pass
63
+
64
+ # Fallback
65
+ name = os.path.basename(os.path.abspath(ck_dir))
66
+ return name, "ckp://Kernel#%s:v1.0" % name, "v1.0"
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # log_action — append one JSONL entry
71
+ # ---------------------------------------------------------------------------
72
+
73
+ def log_action(
74
+ ck_dir: str,
75
+ action_name: str,
76
+ *,
77
+ status: str = "started",
78
+ target_kernel: str = "",
79
+ task_id: str = "",
80
+ goal_id: str = "",
81
+ instance_id: str = "",
82
+ persona: str = "",
83
+ detail: dict | None = None,
84
+ ) -> dict:
85
+ """Append one action entry to the daily ledger file.
86
+
87
+ Args:
88
+ ck_dir: Root directory of the Concept Kernel.
89
+ action_name: Dotted action name (e.g. ``task.create``).
90
+ status: One of ``started``, ``completed``, ``failed``,
91
+ ``timeout``, ``error``.
92
+ target_kernel: Optional target kernel name for cross-kernel actions.
93
+ task_id: Optional task identifier (``T001``).
94
+ goal_id: Optional goal identifier (``G001``).
95
+ instance_id: Optional instance identifier.
96
+ persona: Optional persona label.
97
+ detail: Optional dict of extra payload.
98
+
99
+ Returns:
100
+ The entry dict that was written.
101
+ """
102
+ kernel_name, kernel_urn, _version = _resolve_kernel(ck_dir)
103
+
104
+ now = datetime.now(timezone.utc)
105
+ ts_iso = now.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
106
+ ts_ms = int(now.timestamp() * 1000)
107
+ date_str = now.strftime("%Y-%m-%d")
108
+
109
+ # Build Action URN: ckp://Action#{KernelName}/{action}-{ts_ms}
110
+ action_urn = "ckp://Action#%s/%s-%d" % (kernel_name, action_name, ts_ms)
111
+
112
+ entry: dict = {
113
+ "action_urn": action_urn,
114
+ "action": action_name,
115
+ "target_kernel": target_kernel,
116
+ "task_id": task_id,
117
+ "goal_id": goal_id,
118
+ "instance_id": instance_id,
119
+ "persona": persona,
120
+ "status": status,
121
+ "timestamp": ts_iso,
122
+ "timestamp_ms": ts_ms,
123
+ "prov:wasAssociatedWith": kernel_urn,
124
+ }
125
+
126
+ # Conditional PROV-O fields
127
+ if task_id:
128
+ entry["prov:wasStartedBy"] = "ckp://Task#%s" % task_id
129
+ if goal_id:
130
+ entry["prov:wasInfluencedBy"] = "ckp://Goal#%s" % goal_id
131
+
132
+ # Optional detail payload
133
+ if detail is not None:
134
+ entry["detail"] = detail
135
+
136
+ # Ensure ledger directory exists
137
+ ledger_dir = os.path.join(ck_dir, "storage", "ledger")
138
+ os.makedirs(ledger_dir, exist_ok=True)
139
+
140
+ ledger_file = os.path.join(ledger_dir, "actions-%s.jsonl" % date_str)
141
+ line = json.dumps(entry, separators=(",", ":")) + "\n"
142
+
143
+ # Thread-safe append — hold lock across open+write+flush
144
+ with _write_lock:
145
+ with open(ledger_file, "a") as f:
146
+ f.write(line)
147
+ f.flush()
148
+ os.fsync(f.fileno())
149
+
150
+ return entry
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # read_ledger — read recent entries across daily files
155
+ # ---------------------------------------------------------------------------
156
+
157
+ def read_ledger(ck_dir: str, limit: int = 50) -> list[dict]:
158
+ """Read recent ledger entries in reverse chronological order.
159
+
160
+ Scans daily ``actions-*.jsonl`` files in ``storage/ledger/``, starting
161
+ from the most recent file and working backwards. Stops once *limit*
162
+ entries have been collected.
163
+
164
+ Args:
165
+ ck_dir: Root directory of the Concept Kernel.
166
+ limit: Maximum number of entries to return. Pass ``0`` for
167
+ unlimited (reads every file).
168
+
169
+ Returns:
170
+ List of entry dicts, newest first.
171
+ """
172
+ ledger_dir = os.path.join(ck_dir, "storage", "ledger")
173
+ if not os.path.isdir(ledger_dir):
174
+ return []
175
+
176
+ # Collect daily files and sort descending by filename (date)
177
+ pattern = os.path.join(ledger_dir, "actions-*.jsonl")
178
+ files = sorted(glob(pattern), reverse=True)
179
+
180
+ entries: list[dict] = []
181
+ for fpath in files:
182
+ if limit > 0 and len(entries) >= limit:
183
+ break
184
+
185
+ file_entries = _read_jsonl_file(fpath)
186
+ # Reverse so newest entries in this file come first
187
+ file_entries.reverse()
188
+
189
+ if limit > 0:
190
+ remaining = limit - len(entries)
191
+ entries.extend(file_entries[:remaining])
192
+ else:
193
+ entries.extend(file_entries)
194
+
195
+ return entries
196
+
197
+
198
+ def _read_jsonl_file(path: str) -> list[dict]:
199
+ """Read all valid JSON lines from a file.
200
+
201
+ Silently skips blank or malformed lines.
202
+ """
203
+ entries = []
204
+ try:
205
+ with open(path) as f:
206
+ for line in f:
207
+ line = line.strip()
208
+ if not line:
209
+ continue
210
+ try:
211
+ entries.append(json.loads(line))
212
+ except json.JSONDecodeError:
213
+ continue
214
+ except OSError:
215
+ pass
216
+ return entries
cklib/processor.py ADDED
@@ -0,0 +1,237 @@
1
+ """KernelProcessor — base class for all CK processors.
2
+
3
+ Provides: identity loading, handle_message dispatch via @on decorator,
4
+ status, listen, and CLI argument handling.
5
+
6
+ Usage:
7
+ from cklib import KernelProcessor, on
8
+
9
+ class MyKernel(KernelProcessor):
10
+ @on("my.action")
11
+ def do_something(self, data):
12
+ return {"result": "done"}
13
+
14
+ if __name__ == "__main__":
15
+ MyKernel.run()
16
+ """
17
+ import argparse
18
+ import asyncio
19
+ import json
20
+ import os
21
+ import sys
22
+ from datetime import datetime, timezone
23
+
24
+ import yaml
25
+
26
+
27
+ class KernelProcessor:
28
+ """Base processor for all Concept Kernels."""
29
+
30
+ # Subclass registry of @on handlers
31
+ _handlers = None
32
+
33
+ def __init__(self, ck_dir=None):
34
+ if ck_dir is None:
35
+ # Default: assume tool/processor.py → CK is parent
36
+ frame = sys._getframe(1)
37
+ caller_file = frame.f_globals.get("__file__", "")
38
+ if caller_file:
39
+ ck_dir = os.path.abspath(os.path.join(os.path.dirname(caller_file), ".."))
40
+ else:
41
+ ck_dir = os.getcwd()
42
+
43
+ self.ck_dir = os.path.abspath(ck_dir)
44
+ self.project_root = os.path.abspath(os.path.join(self.ck_dir, "..", ".."))
45
+ self.concepts_dir = os.path.join(self.project_root, "concepts")
46
+ self._identity = None
47
+
48
+ @property
49
+ def identity(self):
50
+ """Load and cache conceptkernel.yaml."""
51
+ if self._identity is None:
52
+ yaml_path = os.path.join(self.ck_dir, "conceptkernel.yaml")
53
+ with open(yaml_path) as f:
54
+ self._identity = yaml.safe_load(f)
55
+ return self._identity
56
+
57
+ @property
58
+ def name(self):
59
+ return self.identity.get("metadata", {}).get("name", "")
60
+
61
+ @property
62
+ def urn(self):
63
+ return self.identity.get("metadata", {}).get("urn", "")
64
+
65
+ @property
66
+ def kernel_type(self):
67
+ return self.identity.get("qualities", {}).get("type", "unknown")
68
+
69
+ @property
70
+ def governance(self):
71
+ return self.identity.get("qualities", {}).get("governance_mode", "unknown")
72
+
73
+ def handle_message(self, msg):
74
+ """Dispatch incoming message to @on handler by action name.
75
+
76
+ Resolution order:
77
+ 1. @on handlers registered by subclass
78
+ 2. Built-in actions (status, ontology)
79
+ 3. Composed actions via COMPOSES/EXTENDS edges
80
+ 4. Error: unknown action
81
+ """
82
+ action = msg.get("action", "status") if isinstance(msg, dict) else "status"
83
+ data = msg.get("data", {}) if isinstance(msg, dict) else {}
84
+
85
+ # Check for @on handler
86
+ handlers = self._get_handlers()
87
+ if action in handlers:
88
+ method_name = handlers[action]
89
+ method = getattr(self, method_name)
90
+ return method(data)
91
+
92
+ # Built-in actions
93
+ if action == "status":
94
+ return self._status()
95
+ elif action == "ontology":
96
+ return self._ontology()
97
+
98
+ # Composed action resolution via edges
99
+ composed = self._resolve_composed(action)
100
+ if composed:
101
+ target_kernel, target_action = composed
102
+ return self._forward_to_edge(target_kernel, action, data)
103
+
104
+ return {"status": "error", "message": "unknown action: %s" % action,
105
+ "kernel": self.name}
106
+
107
+ def _resolve_composed(self, action):
108
+ """Check if action is available via COMPOSES/EXTENDS edges."""
109
+ try:
110
+ from cklib.actions import resolve_composed_actions
111
+ composed = resolve_composed_actions(self.ck_dir)
112
+ if action in composed:
113
+ return composed[action], action
114
+ except Exception:
115
+ pass
116
+ return None
117
+
118
+ def _forward_to_edge(self, target_kernel, action, data):
119
+ """Forward action to a composed kernel via dispatch."""
120
+ try:
121
+ from cklib.dispatch import send_action
122
+ result = send_action(target_kernel, action, data)
123
+ result["_composed_from"] = self.name
124
+ result["_target_kernel"] = target_kernel
125
+ return result
126
+ except Exception as e:
127
+ return {
128
+ "status": "error",
129
+ "message": "edge forward failed: %s" % str(e),
130
+ "kernel": self.name,
131
+ "target_kernel": target_kernel,
132
+ "action": action,
133
+ }
134
+
135
+ def _get_handlers(self):
136
+ """Collect @on handlers from this class and all parents."""
137
+ if self.__class__._handlers is not None:
138
+ return self.__class__._handlers
139
+
140
+ handlers = {}
141
+ for cls in reversed(type(self).__mro__):
142
+ for attr_name in dir(cls):
143
+ attr = getattr(cls, attr_name, None)
144
+ if callable(attr) and hasattr(attr, "_on_action"):
145
+ handlers[attr._on_action] = attr_name
146
+ self.__class__._handlers = handlers
147
+ return handlers
148
+
149
+ def _status(self):
150
+ """Built-in status action."""
151
+ return {
152
+ "status": "ok",
153
+ "kernel": self.name,
154
+ "urn": self.urn,
155
+ "type": self.kernel_type,
156
+ "governance": self.governance,
157
+ "processed_at": datetime.now(timezone.utc).isoformat(),
158
+ }
159
+
160
+ def _ontology(self):
161
+ """Built-in ontology action — return conceptkernel.yaml."""
162
+ return {
163
+ "status": "ok",
164
+ "kernel": self.name,
165
+ "content_type": "text/yaml",
166
+ "data": self.identity,
167
+ }
168
+
169
+ def listen(self):
170
+ """Start NATS listener using NatsKernelLoop."""
171
+ sys.path.insert(0, os.path.join(self.concepts_dir, "CK.Lib", "tool"))
172
+ from nats_kernel import NatsKernelLoop
173
+ loop = NatsKernelLoop(self.ck_dir, self.handle_message)
174
+ asyncio.run(loop.run())
175
+
176
+ def status_cli(self):
177
+ """Print status to stdout."""
178
+ print("[status] %s" % self.name)
179
+ print(" urn: %s" % self.urn)
180
+ print(" type: %s" % self.kernel_type)
181
+ print(" governance: %s" % self.governance)
182
+
183
+ def serve(self, port=8901, api_token=None):
184
+ """Start FastAPI HTTP server wrapping this processor's @on handlers."""
185
+ from cklib.serve import KernelServer
186
+ token = api_token or os.environ.get("CK_API_TOKEN")
187
+ server = KernelServer(self, api_token=token, port=port)
188
+ server.run(port=port)
189
+
190
+ @classmethod
191
+ def run(cls):
192
+ """CLI entry point — parse args, dispatch."""
193
+ parser = argparse.ArgumentParser(description=cls.__doc__ or cls.__name__)
194
+ parser.add_argument("--status", action="store_true", help="Show kernel status")
195
+ parser.add_argument("--listen", action="store_true", help="NATS listener mode")
196
+ parser.add_argument("--serve", action="store_true", help="HTTP server mode")
197
+ parser.add_argument("--port", type=int, default=8901, help="HTTP port (default: 8901)")
198
+ parser.add_argument("--token", type=str, default=None, help="API token for HTTP auth")
199
+ parser.add_argument("--action", type=str, help="Execute action directly")
200
+ parser.add_argument("--data", type=str, default=None, help="JSON data for action")
201
+ parser.add_argument("--pretty", action="store_true", help="Pretty-print output")
202
+
203
+ # Let subclasses add their own args
204
+ cls._add_args(parser)
205
+ args = parser.parse_args()
206
+
207
+ # Resolve ck_dir from the subclass module (not cklib)
208
+ caller_file = sys.modules[cls.__module__].__file__ if cls.__module__ in sys.modules else None
209
+ ck_dir = os.path.abspath(os.path.join(os.path.dirname(caller_file), "..")) if caller_file else None
210
+ instance = cls(ck_dir=ck_dir)
211
+
212
+ if args.listen:
213
+ return instance.listen()
214
+ elif args.serve:
215
+ return instance.serve(port=args.port, api_token=args.token)
216
+ elif args.status:
217
+ return instance.status_cli()
218
+ elif args.action:
219
+ data = json.loads(args.data) if args.data else {}
220
+ result = instance.handle_message({"action": args.action, "data": data})
221
+ indent = 2 if args.pretty else None
222
+ print(json.dumps(result, indent=indent))
223
+ return 0 if result.get("status") != "error" else 1
224
+
225
+ # Let subclass handle remaining args
226
+ return cls._handle_args(instance, args)
227
+
228
+ @classmethod
229
+ def _add_args(cls, parser):
230
+ """Override to add subclass-specific CLI args."""
231
+ pass
232
+
233
+ @classmethod
234
+ def _handle_args(cls, instance, args):
235
+ """Override to handle subclass-specific CLI args."""
236
+ instance.status_cli()
237
+ return 0