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 +1 -0
- core/kind_specs.py +135 -0
- core/llms_gen.py +109 -0
- core/manifest.py +225 -0
- core/schemas.py +22 -0
- core/templates/module_features.md +9 -0
- core/templates/principles.md +29 -0
- core/templates/system.md +54 -0
- gcontext/__init__.py +1 -0
- gcontext/cli.py +574 -0
- gcontext/data/example/info.md +30 -0
- gcontext/data/example/llms.txt +6 -0
- gcontext/data/example/module.yaml +1 -0
- gcontext/data/secrets.md +16 -0
- gcontext_ai-0.2.1.dist-info/METADATA +285 -0
- gcontext_ai-0.2.1.dist-info/RECORD +20 -0
- gcontext_ai-0.2.1.dist-info/WHEEL +5 -0
- gcontext_ai-0.2.1.dist-info/entry_points.txt +2 -0
- gcontext_ai-0.2.1.dist-info/licenses/LICENSE +21 -0
- gcontext_ai-0.2.1.dist-info/top_level.txt +2 -0
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.
|
core/templates/system.md
ADDED
|
@@ -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"
|