gcontext-ai 0.2.1__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.
core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ # empty package marker
core/kind_specs.py ADDED
@@ -0,0 +1,135 @@
1
+ """Per-kind module structure: required files, default growth folders, purpose.
2
+
3
+ Single source of truth for how an `integration`, `task`, or `workflow`
4
+ module is laid out on disk.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class KindSpec:
13
+ kind: str
14
+ purpose: str
15
+ required_files: list[str]
16
+ starter_file: str
17
+ starter_outline: str
18
+ default_growth_folders: list[dict] # [{"name": "notes", "path": "notes/<date-slug>.md"}]
19
+
20
+
21
+ KIND_SPECS: dict[str, KindSpec] = {
22
+ "integration": KindSpec(
23
+ kind="integration",
24
+ purpose="Reusable access to an external service, API, database, or local tool.",
25
+ required_files=["module.yaml", "llms.txt", "info.md"],
26
+ starter_file="info.md",
27
+ starter_outline="Purpose, Access, Operations, Example usage.",
28
+ default_growth_folders=[
29
+ {"name": "notes", "path": "notes/<date-slug>.md"},
30
+ ],
31
+ ),
32
+ "task": KindSpec(
33
+ kind="task",
34
+ purpose="A bounded outcome needing progress tracking, subtasks, findings.",
35
+ required_files=["module.yaml", "llms.txt", "brief.md", "status.md"],
36
+ starter_file="brief.md",
37
+ starter_outline="Goal and initial request; status.md tracks subtasks.",
38
+ default_growth_folders=[
39
+ {"name": "progress", "path": "progress/<date-slug>.md"},
40
+ ],
41
+ ),
42
+ "workflow": KindSpec(
43
+ kind="workflow",
44
+ purpose="A repeatable procedure or playbook that should improve across runs.",
45
+ required_files=["module.yaml", "llms.txt", "steps.md"],
46
+ starter_file="steps.md",
47
+ starter_outline="The repeatable steps, numbered.",
48
+ default_growth_folders=[
49
+ {"name": "runs", "path": "runs/<date-slug>.md"},
50
+ {"name": "lessons", "path": "lessons/<date-slug>.md"},
51
+ ],
52
+ ),
53
+ }
54
+
55
+
56
+ def render_kind_specs_md() -> str:
57
+ """Render KIND_SPECS as markdown for structure.md."""
58
+ lines: list[str] = []
59
+ for spec in KIND_SPECS.values():
60
+ lines.append(f"### `{spec.kind}`")
61
+ lines.append("")
62
+ lines.append(spec.purpose)
63
+ lines.append("")
64
+ lines.append("**Files written at creation:**")
65
+ for f in spec.required_files:
66
+ note = f" - {spec.starter_outline}" if f == spec.starter_file else ""
67
+ lines.append(f"- `{f}`{note}")
68
+ if spec.default_growth_folders:
69
+ lines.append("")
70
+ lines.append(
71
+ "**Default growth folders** (lazy: created on first entry; "
72
+ "seed these into `llms.txt` `## Where to write` at module creation):"
73
+ )
74
+ for ga in spec.default_growth_folders:
75
+ lines.append(f"- `{ga['name']}` -> `{ga['path']}`")
76
+ lines.append("")
77
+ return "\n".join(lines)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class InfoSection:
82
+ name: str
83
+ purpose: str
84
+ format: str # "freeform" or description of strict format
85
+
86
+
87
+ # Core version: 6 sections (no "Python packages" -- that's added by gcontext Cloud)
88
+ INTEGRATION_INFO_SECTIONS: list[InfoSection] = [
89
+ InfoSection(
90
+ "Purpose",
91
+ "One-line summary of what this integration gives the agent access to.",
92
+ "freeform",
93
+ ),
94
+ InfoSection(
95
+ "Where it lives",
96
+ "Upstream system: URL, host, endpoint, or local path.",
97
+ "freeform",
98
+ ),
99
+ InfoSection(
100
+ "Auth & access",
101
+ "How the agent authenticates. Required secret names are written as `UPPER_CASE` in backticks.",
102
+ "strict: secret names as `UPPER_CASE` backtick tokens",
103
+ ),
104
+ InfoSection(
105
+ "Key entities",
106
+ "Domain objects the agent can read or write.",
107
+ "freeform",
108
+ ),
109
+ InfoSection(
110
+ "Operations",
111
+ "Common calls or queries with short examples.",
112
+ "freeform",
113
+ ),
114
+ InfoSection(
115
+ "Examples",
116
+ "Runnable snippets.",
117
+ "freeform",
118
+ ),
119
+ ]
120
+
121
+
122
+ def render_info_md_sections_md(sections: list[InfoSection] | None = None) -> str:
123
+ """Render the integration kind's expected info.md sections for structure.md.
124
+
125
+ Accepts an optional sections list so the platform can pass an extended
126
+ list (e.g., with "Python packages" section). Defaults to core's 6-section list.
127
+ """
128
+ sections = sections or INTEGRATION_INFO_SECTIONS
129
+ lines: list[str] = ["### `info.md` sections (integration kind)", ""]
130
+ for s in sections:
131
+ lines.append(f"- `## {s.name}` -- {s.purpose}")
132
+ if s.format != "freeform":
133
+ lines.append(f" - Format: {s.format}")
134
+ lines.append("")
135
+ return "\n".join(lines)
core/llms_gen.py ADDED
@@ -0,0 +1,109 @@
1
+ """Generate workspace-level files: llms.txt, system.md, structure.md.
2
+
3
+ These files are regenerated on every load/unload operation.
4
+ """
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from core.kind_specs import render_kind_specs_md, render_info_md_sections_md
11
+ from core.manifest import render_schema_md
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ def extract_module_summary(llms_txt_content: str) -> str:
17
+ """Extract the > blockquote description from a module's llms.txt."""
18
+ for line in llms_txt_content.splitlines():
19
+ if line.startswith("> "):
20
+ return line[2:].strip()
21
+ return ""
22
+
23
+
24
+ def generate_root_llms_txt(context_dir: Path) -> None:
25
+ """Generate root llms.txt from loaded modules' llms.txt files.
26
+
27
+ Skips dot-prefixed directories (e.g. .claude).
28
+ """
29
+ entries = []
30
+ for mod_dir in sorted(context_dir.iterdir()):
31
+ if not mod_dir.is_dir() or mod_dir.name.startswith("."):
32
+ continue
33
+ llms_file = mod_dir / "llms.txt"
34
+ if llms_file.exists():
35
+ summary = extract_module_summary(llms_file.read_text())
36
+ else:
37
+ summary = "Context module"
38
+ entries.append(f"- [{mod_dir.name}]({mod_dir.name}/llms.txt): {summary}")
39
+
40
+ lines = ["# Context", ""]
41
+ lines.extend(entries)
42
+ (context_dir / "llms.txt").write_text("\n".join(lines) + "\n")
43
+
44
+
45
+ def generate_structure_md(context_dir: Path, info_sections: list | None = None) -> None:
46
+ """Write context/structure.md from manifest schema and kind specs.
47
+
48
+ Auto-generated reference for module.yaml fields, per-kind file
49
+ requirements, and info.md section structure.
50
+
51
+ Accepts optional info_sections so the platform can pass its extended
52
+ list (with "Python packages" section). Defaults to core's 6-section list.
53
+ """
54
+ body = "\n".join([
55
+ "# Module Structure",
56
+ "",
57
+ "Auto-generated from `manifest.py` and `kind_specs.py`. Do not edit by hand.",
58
+ "",
59
+ render_schema_md(),
60
+ "",
61
+ "## Module kinds",
62
+ "",
63
+ render_kind_specs_md(),
64
+ "## Starter file structure",
65
+ "",
66
+ render_info_md_sections_md(info_sections),
67
+ ])
68
+ (context_dir / "structure.md").write_text(body)
69
+
70
+
71
+ def generate_system_md(context_dir: Path, template_content: str) -> None:
72
+ """Write context/system.md from a template string + loaded modules table.
73
+
74
+ The caller is responsible for reading/assembling the template content.
75
+ This function appends the loaded modules table at the end.
76
+ Skips dot-prefixed directories.
77
+ """
78
+ rows: list[tuple[str, str, str]] = []
79
+ for mod_dir in sorted(context_dir.iterdir()):
80
+ if not mod_dir.is_dir() or mod_dir.name.startswith("."):
81
+ continue
82
+ kind = "unknown"
83
+ manifest_file = mod_dir / "module.yaml"
84
+ if manifest_file.exists():
85
+ try:
86
+ data = yaml.safe_load(manifest_file.read_text())
87
+ kind = data.get("kind", "integration") if data else "unknown"
88
+ except Exception:
89
+ log.warning("Failed to parse %s", manifest_file)
90
+ llms_file = mod_dir / "llms.txt"
91
+ summary = ""
92
+ if llms_file.exists():
93
+ summary = extract_module_summary(llms_file.read_text())
94
+ rows.append((mod_dir.name, kind, summary))
95
+
96
+ lines = [
97
+ "",
98
+ "## Loaded modules",
99
+ "",
100
+ ]
101
+ if rows:
102
+ lines.append("| Module | Kind | Summary |")
103
+ lines.append("|--------|------|---------|")
104
+ for name, kind, summary in rows:
105
+ lines.append(f"| {name} | {kind} | {summary} |")
106
+ else:
107
+ lines.append("No modules loaded.")
108
+
109
+ (context_dir / "system.md").write_text(template_content + "\n".join(lines) + "\n")
core/manifest.py ADDED
@@ -0,0 +1,225 @@
1
+ """Module manifest (module.yaml) read/write service.
2
+
3
+ Each module declares its name, kind, secrets, dependencies, and optional
4
+ jobs in module.yaml. Replaces per-module .env.schema and requirements.txt.
5
+ """
6
+ import re
7
+ import typing
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, Field, field_validator
13
+
14
+ ModuleKind = Literal["integration", "task", "workflow"]
15
+
16
+
17
+ _EVERY_RE = re.compile(r"^(\d+)([smh])$")
18
+ _EVERY_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600}
19
+
20
+ # Must match jobs.TICK_SECONDS — keep as a constant here to avoid
21
+ # importing jobs.py from manifest.py (circular).
22
+ _MIN_EVERY_SECONDS = 30
23
+
24
+
25
+ def parse_every(value: str) -> int:
26
+ """Convert '30s' / '5m' / '1h' to a number of seconds.
27
+
28
+ Raises ValueError on any malformed input or values below the
29
+ scheduler tick (30 s) — sub-tick intervals would round up anyway
30
+ and surprise the user.
31
+ """
32
+ if not isinstance(value, str):
33
+ raise ValueError(f"every must be a string, got {type(value).__name__}")
34
+ match = _EVERY_RE.fullmatch(value)
35
+ if not match:
36
+ raise ValueError(
37
+ f"invalid every '{value}': expected <digits><s|m|h>, e.g. '30s', '5m', '1h'"
38
+ )
39
+ n = int(match.group(1))
40
+ if n == 0:
41
+ raise ValueError(f"invalid every '{value}': must be > 0")
42
+ seconds = n * _EVERY_UNIT_SECONDS[match.group(2)]
43
+ if seconds < _MIN_EVERY_SECONDS:
44
+ raise ValueError(
45
+ f"invalid every '{value}': minimum is {_MIN_EVERY_SECONDS}s"
46
+ )
47
+ return seconds
48
+
49
+
50
+ class JobSpec(BaseModel):
51
+ name: str = Field(..., description="Job identifier; unique within the module.")
52
+ script: str = Field(
53
+ ...,
54
+ description="Relative path to the script inside the module directory. Absolute paths and `..` traversal are rejected.",
55
+ )
56
+ every: str = Field(
57
+ ...,
58
+ description="Cron-like cadence as `<digits><s|m|h>` (e.g. `30s`, `5m`, `1h`). Minimum 30s.",
59
+ )
60
+
61
+ @field_validator("script")
62
+ @classmethod
63
+ def _no_absolute_or_traversal(cls, v: str) -> str:
64
+ if v.startswith("/") or ".." in Path(v).parts:
65
+ raise ValueError(f"invalid script path '{v}': must be relative inside the module")
66
+ return v
67
+
68
+ @field_validator("every")
69
+ @classmethod
70
+ def _validate_every(cls, v: str) -> str:
71
+ parse_every(v) # raises on bad values
72
+ return v
73
+
74
+ @property
75
+ def every_seconds(self) -> int:
76
+ return parse_every(self.every)
77
+
78
+
79
+ class ModuleManifest(BaseModel):
80
+ name: str = Field(
81
+ ...,
82
+ description="Folder slug; must match the module directory name.",
83
+ )
84
+ kind: ModuleKind = Field(
85
+ default="integration",
86
+ description='One of: "integration", "task", "workflow". Drives how the module is rendered in the sidebar.',
87
+ )
88
+ archived: bool = Field(
89
+ default=False,
90
+ description="Hide from the active sidebar without deleting. Archived modules appear under a collapsed Archived section and cannot be loaded into the workspace.",
91
+ )
92
+ secrets: list[str] = Field(
93
+ default_factory=list,
94
+ description="Environment variable names this module needs at runtime. Resolved via varlock from the workspace .env.schema.",
95
+ )
96
+ dependencies: list[str] = Field(
97
+ default_factory=list,
98
+ description="Python packages required at runtime. Installed into the platform venv at boot.",
99
+ )
100
+ icon: str = Field(
101
+ default="",
102
+ description="Icon name for the module's app card (e.g. 'globe', 'server'). Empty means auto-derived.",
103
+ )
104
+ jobs: list[JobSpec] = Field(
105
+ default_factory=list,
106
+ description="Cron-like scheduled scripts owned by this module.",
107
+ )
108
+
109
+
110
+ def read_manifest(module_dir: Path) -> ModuleManifest:
111
+ """Read module.yaml from a module directory.
112
+
113
+ Returns a manifest with defaults (name inferred from dir) if the
114
+ file doesn't exist.
115
+ """
116
+ manifest_path = module_dir / "module.yaml"
117
+ if not manifest_path.exists():
118
+ return ModuleManifest(name=module_dir.name)
119
+ raw = yaml.safe_load(manifest_path.read_text()) or {}
120
+ raw.setdefault("name", module_dir.name)
121
+ return ModuleManifest(**raw)
122
+
123
+
124
+ def write_manifest(module_dir: Path, manifest: ModuleManifest) -> None:
125
+ """Write a ModuleManifest to module.yaml, omitting empty optional fields."""
126
+ data: dict[str, str | bool | list[str]] = {"name": manifest.name}
127
+ if manifest.kind != "integration":
128
+ data["kind"] = manifest.kind
129
+ if manifest.archived:
130
+ data["archived"] = manifest.archived
131
+ if manifest.icon:
132
+ data["icon"] = manifest.icon
133
+ if manifest.secrets:
134
+ data["secrets"] = manifest.secrets
135
+ if manifest.dependencies:
136
+ data["dependencies"] = manifest.dependencies
137
+ if manifest.jobs:
138
+ data["jobs"] = [
139
+ {"name": j.name, "script": j.script, "every": j.every}
140
+ for j in manifest.jobs
141
+ ]
142
+ (module_dir / "module.yaml").write_text(
143
+ yaml.dump(data, default_flow_style=False, sort_keys=False)
144
+ )
145
+
146
+
147
+ _SLUG_NON_ALPHANUM = re.compile(r"[^a-z0-9-]")
148
+ _SLUG_DASH_RUN = re.compile(r"-+")
149
+
150
+
151
+ def slugify_task_name(name: str) -> str:
152
+ """Convert a human task name to a folder-safe slug."""
153
+ slug = name.strip().lower().replace("_", "-")
154
+ slug = _SLUG_NON_ALPHANUM.sub("-", slug)
155
+ slug = _SLUG_DASH_RUN.sub("-", slug)
156
+ return slug.strip("-")
157
+
158
+
159
+ _PRIMITIVE_NAMES = {
160
+ str: "str",
161
+ int: "int",
162
+ float: "float",
163
+ bool: "bool",
164
+ }
165
+
166
+
167
+ def _format_default(field) -> str:
168
+ if field.is_required():
169
+ return "required"
170
+ factory = field.default_factory
171
+ if factory is not None:
172
+ try:
173
+ default = factory()
174
+ except TypeError:
175
+ default = None
176
+ else:
177
+ default = field.default
178
+ if isinstance(default, bool):
179
+ return "default false" if default is False else "default true"
180
+ if isinstance(default, str):
181
+ return f'default "{default}"'
182
+ if isinstance(default, list) and not default:
183
+ return "default []"
184
+ return f"default {default!r}"
185
+
186
+
187
+ def _format_type(annotation) -> str:
188
+ if annotation in _PRIMITIVE_NAMES:
189
+ return _PRIMITIVE_NAMES[annotation]
190
+ origin = getattr(annotation, "__origin__", None)
191
+ if origin is list:
192
+ (inner,) = annotation.__args__
193
+ if inner is JobSpec:
194
+ return "list[JobSpec]"
195
+ return f"list[{_PRIMITIVE_NAMES.get(inner, inner.__name__)}]"
196
+ if typing.get_origin(annotation) is Literal:
197
+ return " | ".join(repr(v) for v in typing.get_args(annotation))
198
+ return getattr(annotation, "__name__", str(annotation))
199
+
200
+
201
+ def render_schema_md() -> str:
202
+ """Render ModuleManifest as a markdown schema block for prompt injection.
203
+
204
+ Walks the model's fields and emits one bullet per field with type,
205
+ default, and description. Single source of truth for what the AI
206
+ should write into module.yaml — adding a new field here makes it
207
+ visible to the chat agent automatically.
208
+ """
209
+ lines = ["## module.yaml schema", ""]
210
+ for field_name, field in ModuleManifest.model_fields.items():
211
+ type_str = _format_type(field.annotation)
212
+ default_str = _format_default(field)
213
+ desc = field.description or ""
214
+ lines.append(f"- `{field_name}` ({type_str}, {default_str}) — {desc}")
215
+ if type_str == "list[JobSpec]":
216
+ for sub_name, sub in JobSpec.model_fields.items():
217
+ sub_type = _format_type(sub.annotation)
218
+ sub_default = _format_default(sub)
219
+ sub_desc = sub.description or ""
220
+ lines.append(f" - `{sub_name}` ({sub_type}, {sub_default}) — {sub_desc}")
221
+ lines.append("")
222
+ lines.append(
223
+ "Omit fields that match their default. Unknown fields are rejected."
224
+ )
225
+ return "\n".join(lines)
core/schemas.py ADDED
@@ -0,0 +1,22 @@
1
+ import re
2
+
3
+ _VALID_MODULE_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
4
+
5
+
6
+ def validate_module_name(name: str) -> str:
7
+ name = name.strip()
8
+ if not name or not _VALID_MODULE_NAME.match(name):
9
+ raise ValueError(
10
+ f"Invalid module name: '{name}'. "
11
+ "Must start with a letter or number, then letters, numbers, hyphens, underscores."
12
+ )
13
+ return name
14
+
15
+
16
+ def validate_module_file_path(file_path: str) -> str:
17
+ file_path = file_path.strip().strip("/")
18
+ if not file_path:
19
+ raise ValueError("File path cannot be empty")
20
+ if ".." in file_path:
21
+ raise ValueError("File path cannot contain '..'")
22
+ return file_path
@@ -0,0 +1,9 @@
1
+ # Module Features — Optional Capabilities
2
+
3
+ A module is just a folder. Beyond `module.yaml` + `llms.txt` + the kind's starter file (info.md / brief.md / steps.md), a module can optionally carry:
4
+
5
+ ## Scripts
6
+
7
+ Plain Python files inside the module that the agent can run on demand. The canonical example is `verify.py` — a quick sanity check that the integration's credentials and network access work.
8
+
9
+ When to add one: when the module benefits from a repeatable check, fetch, or transformation that the user (or another turn) will want to invoke later.
@@ -0,0 +1,29 @@
1
+ # Principles — Modifying Context
2
+
3
+ These are the rules for creating, editing, or extending modules.
4
+
5
+ ## Module locations
6
+
7
+ Modules live at `modules-repo/<slug>/`. The `context/` directory contains symlinks for the subset of modules currently loaded into the active workspace — **it is a view, not a storage location.**
8
+
9
+ Two operations that look similar but are NOT the same:
10
+
11
+ - **Create / edit a module.** You write files at `modules-repo/<slug>/...`. This is the only kind of write you perform.
12
+ - **Load a module into the workspace.** This creates a symlink inside `context/`. It is managed by the `gcontext` CLI, not by you.
13
+
14
+ Each module is a folder with at least `module.yaml` and `llms.txt`.
15
+
16
+ ## Reading context before modifying
17
+
18
+ Before answering anything that references or implies a topic likely covered by an existing module, read that module's `llms.txt` first. If `llms.txt` declares a `## Where to write` section, that section governs where any subsequent appends go (path, naming pattern, template). Do NOT invent a new location.
19
+
20
+ If no module matches, you may answer from general knowledge — but if the conversation produces durable content (a finding, a note worth keeping, a new procedure), propose a new module rather than silently dropping it.
21
+
22
+ ## Module schema and kinds
23
+
24
+ See [structure.md](structure.md) for the authoritative module.yaml schema and the per-kind file layout (integration / task / workflow). That file is auto-generated from code — read it; do not invent fields.
25
+
26
+ ## When to propose a new module vs append to an existing one
27
+
28
+ - **Append** when the content is one more instance of a pattern the module already tracks (another note, another run, another finding).
29
+ - **New module** when the content concerns a different external service, a different task with its own goal, or a different repeatable procedure. New modules cost almost nothing — favor a small new module over stretching an existing one.
@@ -0,0 +1,54 @@
1
+ # System
2
+
3
+ You are an AI agent powered by loaded context modules.
4
+
5
+ You are direct, efficient, and familiar with the loaded context. No hedging, no filler. Lead with the answer.
6
+
7
+ ## Two responsibilities
8
+
9
+ 1. **Operate modules** — read context, run scripts, answer questions. This file covers that.
10
+ 2. **Modify context** — create or edit modules. See [principles.md](principles.md) before doing this.
11
+
12
+ ## How to operate a module
13
+
14
+ **CRITICAL: Assume every question is potentially answerable through your modules. Always navigate the `llms.txt` tree before claiming you can't help. Never dismiss a question as out of scope without checking first.**
15
+
16
+ When asked anything, start by asking: **"Which module do I need?"**
17
+
18
+ 1. Read `llms.txt` — see all loaded modules with one-line descriptions
19
+ 2. Pick the relevant module(s) based on the question
20
+ 3. Read that module's `llms.txt` to find the specific file you need
21
+ 4. Read the actual content, write a script if needed, and get the answer
22
+
23
+ ## Searching inside modules
24
+
25
+ Your working directory uses symlinks to loaded modules. **Glob and Grep do not follow symlinks**, so they cannot find files inside modules from the workspace root.
26
+
27
+ To search or list files within a module, point the tool at the module's real path:
28
+ - **Glob/Grep path**: `modules-repo/<module-name>/` (not the workspace root)
29
+ - **Read**: works normally with relative paths like `<module-name>/file.md`
30
+
31
+ When module content references a file in another module (e.g. `ai-browsing/stripe/file.md`), resolve it with Read from the workspace root first. If the module isn't loaded, read from `modules-repo/<module-name>/` instead.
32
+
33
+ ## Setup per turn
34
+
35
+ 1. Read [llms.txt](llms.txt) — orient yourself in the module hierarchy
36
+ 2. For any module you need, read its `module.yaml` — declares required secrets and dependencies
37
+
38
+ ## Secrets
39
+
40
+ See [secrets.md](secrets.md) for how secrets work in this environment.
41
+
42
+ A module needs secrets if and only if its `module.yaml` declares a `secrets:` list (variable names only, no values).
43
+
44
+ ## Vocabulary
45
+
46
+ "Task", "integration", and "workflow" are **module kinds** — not abstract concepts. When the user says "create a task", "add an integration", or "set up a workflow", they mean create a new module with that kind. See [structure.md](structure.md) for the file layout of each kind.
47
+
48
+ ## Module features — scripts, cron jobs, apps
49
+
50
+ Modules can carry scripts, scheduled cron jobs, and browser apps. If the user asks to set up a cron job, build an app, add a verify script, or any similar capability, read [module_features.md](module_features.md) for the catalog and conventions.
51
+
52
+ ## Modifying context
53
+
54
+ If you are asked to create a task, add an integration, start a workflow, create a new module, edit module files, or change anything in `modules-repo/<slug>/`, read [principles.md](principles.md) and [structure.md](structure.md) first. They own the rules for where things go, module kinds, and how writes are gated.
gcontext/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"