mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
refactor(zed): port setup CLI to Node, drop the Python dependency (#2599)
* refactor(zed): port setup CLI to Node, drop the Python dependency The Zed integration is configuration-only — it writes the `context_servers` entry into Zed's settings.json and a recall/retain rule into AGENTS.md — and the MCP server it configures runs via `npx mcp-remote`, so Node.js was already a hard requirement. Requiring Python *as well* just to write two config files meant users needed two runtimes. Port the `hindsight-zed` CLI to a zero-dependency Node CLI so the integration needs only Node: - Node CLI under `src/` + `bin/hindsight-zed.js`, shipped via `package.json` (matches the existing TypeScript integrations; release-integration.yml already detects package.json for npm publishing). - Behavior-preserving: same commands (`init`/`status`/`uninstall`), flags, `--print-only`, env/file/flag config resolution, JSONC-safe settings edits, and fenced AGENTS.md rule block. - Tests ported to Node's built-in runner (`node --test`) — 21 tests. - CI (`test.yml`) updated to run `npm test` on Node 22 instead of pytest. - Removes the Python package (`hindsight_zed/`, `pyproject.toml`, `uv.lock`, Python `tests/`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(zed): make the Node package publishable by the release workflow The release workflow classifies any integration with a package.json as `type=typescript` and unconditionally runs `npm ci` + `npm run build` in the integration dir. This zero-dependency, no-build JS package had neither, so `integrations/zed/v*` would fail at release time (invisible in test CI, which only runs `npm test`): - add a no-op `build` script so `npm run build` succeeds - commit package-lock.json so `npm ci` succeeds (it refuses to run without one, even with zero deps); lockfile has no node_modules entries, so check-integration-lockfiles.sh passes trivially - drop the stray settings.json (a local `init` scaffold accidentally committed) and gitignore it Verified locally: node --test (21/21), npm ci, npm run build, and npm publish --dry-run all pass; tarball ships only bin/src/README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WEHyNxWfn1miSWRW6NUtEW * docs(zed): update setup to Node/npx (drop pip install) * refactor(zed): scope npm package as @vectorize-io/hindsight-zed Match the scoped-name convention of the other TS integrations (@vectorize-io/hindsight-ai-sdk, -chat, -openclaw). CLI/bin command stays 'hindsight-zed'; npx/global-install references updated to the scoped name. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: DK09876 <dk09876@Dikshants-MacBook-Pro.local>
This commit is contained in:
@@ -520,22 +520,17 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install package and pytest
|
||||
working-directory: ./hindsight-integrations/zed
|
||||
# Installs the package (incl. the zstandard runtime dep) so the threads.db
|
||||
# reader tests can decompress Zed's zstd blobs.
|
||||
run: pip install -e . pytest
|
||||
node-version: '22'
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/zed
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
run: python -m pytest tests/ -v -m "not requires_real_llm"
|
||||
# Config-only integration with no dependencies — it uses Node's built-in
|
||||
# test runner. The runtime MCP bridge is `npx mcp-remote` (Node), so this
|
||||
# integration requires only Node.js (no Python).
|
||||
run: npm test
|
||||
|
||||
test-omo-integration:
|
||||
needs: [detect-changes]
|
||||
|
||||
@@ -19,8 +19,16 @@ Zed doesn't yet have native HTTP-MCP transport, so the server is connected throu
|
||||
|
||||
## Setup
|
||||
|
||||
`hindsight-zed` is a zero-dependency Node CLI — Node.js is the only requirement (already needed for the `mcp-remote` bridge). Run it straight from npm with `npx`:
|
||||
|
||||
```bash
|
||||
pip install hindsight-zed
|
||||
npx @vectorize-io/hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
|
||||
```
|
||||
|
||||
Or install it globally for a persistent command:
|
||||
|
||||
```bash
|
||||
npm install -g @vectorize-io/hindsight-zed
|
||||
hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
|
||||
```
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Local scaffold written by `hindsight-zed init` when pointed at this dir
|
||||
/settings.json
|
||||
|
||||
@@ -22,12 +22,21 @@ Zed has no pre-prompt hook, but it does support two things this integration uses
|
||||
|
||||
Zed doesn't yet have native HTTP-MCP transport, so the server is connected
|
||||
through the [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio bridge
|
||||
(run via `npx`) — that means you need Node.js installed.
|
||||
(run via `npx`). Because that bridge already runs on Node.js, this setup tool is
|
||||
a Node CLI too — so **Node.js is the only requirement**.
|
||||
|
||||
## Install
|
||||
|
||||
No global install needed — run it straight from npm with `npx`:
|
||||
|
||||
```bash
|
||||
pip install hindsight-zed
|
||||
npx @vectorize-io/hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
|
||||
```
|
||||
|
||||
Or install it for a persistent command:
|
||||
|
||||
```bash
|
||||
npm install -g @vectorize-io/hindsight-zed
|
||||
hindsight-zed init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-memory
|
||||
```
|
||||
|
||||
@@ -45,12 +54,14 @@ open local server).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `hindsight-zed init` | Add the MCP server + recall/retain rule |
|
||||
| `hindsight-zed status` | Show whether the server + rule are configured |
|
||||
| `hindsight-zed uninstall` | Remove the server + rule |
|
||||
| `hindsight-zed init --print-only` | Print the config to add manually |
|
||||
| Command | Description |
|
||||
| --------------------------------- | --------------------------------------------- |
|
||||
| `hindsight-zed init` | Add the MCP server + recall/retain rule |
|
||||
| `hindsight-zed status` | Show whether the server + rule are configured |
|
||||
| `hindsight-zed uninstall` | Remove the server + rule |
|
||||
| `hindsight-zed init --print-only` | Print the config to add manually |
|
||||
|
||||
(Prefix any of these with `npx ` if you didn't install globally.)
|
||||
|
||||
## What gets written
|
||||
|
||||
@@ -63,12 +74,14 @@ open local server).
|
||||
"source": "custom",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y", "mcp-remote",
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://api.hindsight.vectorize.io/mcp/my-memory/",
|
||||
"--header", "Authorization: Bearer YOUR_HINDSIGHT_API_KEY"
|
||||
]
|
||||
}
|
||||
}
|
||||
"--header",
|
||||
"Authorization: Bearer YOUR_HINDSIGHT_API_KEY",
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -78,20 +91,20 @@ the start of each task and `retain` durable facts.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Env var | Default |
|
||||
| --- | --- | --- |
|
||||
| API URL | `HINDSIGHT_API_URL` | `https://api.hindsight.vectorize.io` |
|
||||
| API token | `HINDSIGHT_API_TOKEN` | _(none; required for Cloud)_ |
|
||||
| Bank id | `HINDSIGHT_ZED_BANK_ID` | `zed` |
|
||||
| Setting | Env var | Default |
|
||||
| --------- | ----------------------- | ------------------------------------ |
|
||||
| API URL | `HINDSIGHT_API_URL` | `https://api.hindsight.vectorize.io` |
|
||||
| API token | `HINDSIGHT_API_TOKEN` | _(none; required for Cloud)_ |
|
||||
| Bank id | `HINDSIGHT_ZED_BANK_ID` | `zed` |
|
||||
|
||||
These can also live in `~/.hindsight/zed.json` (written by `init`).
|
||||
|
||||
## Development
|
||||
|
||||
Requires Node.js ≥ 18.3. There are no dependencies to install.
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest tests -v -m 'not requires_real_llm' # deterministic suite
|
||||
uv run pytest tests -v -m requires_real_llm # gated MCP-endpoint check
|
||||
node --test # run the test suite
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { main } from "../src/cli.js";
|
||||
|
||||
process.exit(main());
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Hindsight memory integration for the Zed editor."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,173 +0,0 @@
|
||||
"""CLI for the Hindsight Zed integration.
|
||||
|
||||
``hindsight-zed init`` wires Zed's MCP ``context_servers`` to the Hindsight MCP
|
||||
endpoint and writes a recall/retain rule into Zed's global instructions file.
|
||||
After that, Zed's Agent Panel has ``recall``/``retain``/``reflect`` tools and is
|
||||
told (via the rule) to use them automatically. There is no background process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from . import __version__
|
||||
from .config import USER_CONFIG_FILE, ZedConfig, load_config
|
||||
from .rules_file import RULE_TEXT, clear_rule, default_rules_path, write_rule
|
||||
from .rules_file import is_installed as rule_installed
|
||||
from .zed_settings import (
|
||||
SettingsResult,
|
||||
apply_to_settings,
|
||||
build_context_server,
|
||||
default_settings_path,
|
||||
remove_from_settings,
|
||||
render_snippet,
|
||||
)
|
||||
from .zed_settings import (
|
||||
is_installed as server_installed,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstallOutcome:
|
||||
"""Result of an ``init``: how the settings file changed and where the rule went."""
|
||||
|
||||
settings: SettingsResult
|
||||
rules_path: Path
|
||||
|
||||
|
||||
def build_install(config: ZedConfig, settings_path: Path, rules_path: Path) -> InstallOutcome:
|
||||
"""Apply the MCP server entry and the recall/retain rule (the testable core)."""
|
||||
server = build_context_server(config.hindsight_api_url, config.hindsight_api_token, config.bank_id)
|
||||
settings = apply_to_settings(settings_path, server)
|
||||
write_rule(rules_path)
|
||||
return InstallOutcome(settings=settings, rules_path=rules_path)
|
||||
|
||||
|
||||
def _config_path(args: argparse.Namespace) -> Path:
|
||||
return Path(args.config_path) if args.config_path else USER_CONFIG_FILE
|
||||
|
||||
|
||||
def _resolve_config(args: argparse.Namespace) -> ZedConfig:
|
||||
"""Config from file/env, overridden by any explicitly-passed CLI flags."""
|
||||
cfg = load_config(config_file=_config_path(args))
|
||||
if args.api_url:
|
||||
cfg.hindsight_api_url = args.api_url
|
||||
if args.api_token:
|
||||
cfg.hindsight_api_token = args.api_token
|
||||
if args.bank_id:
|
||||
cfg.bank_id = args.bank_id
|
||||
return cfg
|
||||
|
||||
|
||||
def _scaffold_config(cfg: ZedConfig, config_path: Path) -> None:
|
||||
"""Persist the resolved connection settings so re-runs remember them."""
|
||||
if config_path.is_file():
|
||||
return
|
||||
data = {"hindsightApiUrl": cfg.hindsight_api_url, "bankId": cfg.bank_id}
|
||||
if cfg.hindsight_api_token:
|
||||
data["hindsightApiToken"] = cfg.hindsight_api_token
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def cmd_init(args: argparse.Namespace) -> None:
|
||||
cfg = _resolve_config(args)
|
||||
settings_path = Path(args.settings_path) if args.settings_path else default_settings_path()
|
||||
rules_path = Path(args.rules_path) if args.rules_path else default_rules_path()
|
||||
server = build_context_server(cfg.hindsight_api_url, cfg.hindsight_api_token, cfg.bank_id)
|
||||
|
||||
if args.print_only:
|
||||
print("Add this to your Zed settings.json:\n")
|
||||
print(render_snippet(server))
|
||||
print("\nAnd add this rule to ~/.config/zed/AGENTS.md:\n")
|
||||
print(RULE_TEXT)
|
||||
return
|
||||
|
||||
print("Setting up Hindsight for Zed ...")
|
||||
_scaffold_config(cfg, _config_path(args))
|
||||
outcome = build_install(cfg, settings_path, rules_path)
|
||||
|
||||
if outcome.settings.action == "manual":
|
||||
print(f" Your {outcome.settings.path} has comments, so I won't rewrite it.")
|
||||
print(" Add this `context_servers` entry yourself:\n")
|
||||
print(render_snippet(server))
|
||||
else:
|
||||
verb = {"created": "Created", "merged": "Updated", "unchanged": "Already configured in"}[
|
||||
outcome.settings.action
|
||||
]
|
||||
print(f" {verb} {outcome.settings.path} (MCP server: hindsight → bank '{cfg.bank_id}')")
|
||||
print(f" Wrote recall/retain rule to {outcome.rules_path}")
|
||||
|
||||
if shutil.which("npx") is None:
|
||||
print("\n warning: `npx` (Node.js) was not found on PATH. Zed runs the MCP")
|
||||
print(" bridge via `npx mcp-remote`, so install Node.js for the server to start.")
|
||||
|
||||
print("\nDone. Restart Zed, open the Agent Panel, and the `hindsight` MCP server")
|
||||
print("should show a green dot. Memory recall/retain then happen automatically.")
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> None:
|
||||
settings_path = Path(args.settings_path) if args.settings_path else default_settings_path()
|
||||
rules_path = Path(args.rules_path) if args.rules_path else default_rules_path()
|
||||
print(f"MCP server in {settings_path}: {'installed' if server_installed(settings_path) else 'not installed'}")
|
||||
print(f"Recall/retain rule in {rules_path}: {'installed' if rule_installed(rules_path) else 'not installed'}")
|
||||
|
||||
|
||||
def cmd_uninstall(args: argparse.Namespace) -> None:
|
||||
settings_path = Path(args.settings_path) if args.settings_path else default_settings_path()
|
||||
rules_path = Path(args.rules_path) if args.rules_path else default_rules_path()
|
||||
result = remove_from_settings(settings_path)
|
||||
if result.action == "manual":
|
||||
print(f" {settings_path} has comments — remove the `hindsight` context_servers entry yourself.")
|
||||
elif result.action == "removed":
|
||||
print(f" Removed the hindsight MCP server from {settings_path}")
|
||||
else:
|
||||
print(f" No hindsight MCP server found in {settings_path}")
|
||||
clear_rule(rules_path)
|
||||
print(f" Removed the recall/retain rule from {rules_path}")
|
||||
|
||||
|
||||
def _add_path_overrides(parser: argparse.ArgumentParser) -> None:
|
||||
# Hidden overrides used by tests and advanced setups.
|
||||
parser.add_argument("--settings-path", default=None, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--rules-path", default=None, help=argparse.SUPPRESS)
|
||||
parser.add_argument("--config-path", default=None, help=argparse.SUPPRESS)
|
||||
|
||||
|
||||
def main(argv: Optional[list] = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="hindsight-zed", description="Hindsight memory for Zed (via MCP)")
|
||||
parser.add_argument("--version", action="version", version=f"hindsight-zed {__version__}")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
init_p = sub.add_parser("init", help="Configure Zed's MCP server + recall/retain rule")
|
||||
init_p.add_argument("--api-url", default=None, help="Hindsight API URL (default: cloud)")
|
||||
init_p.add_argument("--api-token", default=None, help="Hindsight API token (for Cloud)")
|
||||
init_p.add_argument("--bank-id", default=None, help="Memory bank for the MCP server (default: zed)")
|
||||
init_p.add_argument("--print-only", action="store_true", help="Print the config to add manually; write nothing")
|
||||
_add_path_overrides(init_p)
|
||||
init_p.set_defaults(func=cmd_init)
|
||||
|
||||
status_p = sub.add_parser("status", help="Show whether the MCP server + rule are configured")
|
||||
_add_path_overrides(status_p)
|
||||
status_p.set_defaults(func=cmd_status)
|
||||
|
||||
uninst_p = sub.add_parser("uninstall", help="Remove the MCP server + rule")
|
||||
_add_path_overrides(uninst_p)
|
||||
uninst_p.set_defaults(func=cmd_uninstall)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
if not hasattr(args, "func"):
|
||||
parser.print_help()
|
||||
return 1
|
||||
args.func(args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Configuration for the Hindsight Zed integration.
|
||||
|
||||
Settings layer (later wins): built-in defaults → ``~/.hindsight/zed.json`` →
|
||||
environment variables. Resolved into a typed :class:`ZedConfig`.
|
||||
|
||||
The integration is configuration-only: it wires Zed's MCP ``context_servers`` to
|
||||
the Hindsight MCP endpoint and writes a recall/retain rule into Zed's global
|
||||
instructions file. Memory operations happen through the MCP server at runtime,
|
||||
so there is no daemon or direct API client here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Cross-integration cloud-default convention.
|
||||
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
|
||||
DEFAULT_BANK_ID = "zed"
|
||||
|
||||
USER_CONFIG_FILE = Path.home() / ".hindsight" / "zed.json"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZedConfig:
|
||||
"""Resolved configuration for the Zed MCP setup."""
|
||||
|
||||
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
|
||||
hindsight_api_token: Optional[str] = None
|
||||
# The memory bank the Zed MCP server is scoped to (it's the last path
|
||||
# segment of the MCP endpoint URL).
|
||||
bank_id: str = DEFAULT_BANK_ID
|
||||
|
||||
|
||||
# user-config file key -> attribute
|
||||
_FILE_KEYS = {
|
||||
"hindsightApiUrl": "hindsight_api_url",
|
||||
"hindsightApiToken": "hindsight_api_token",
|
||||
"bankId": "bank_id",
|
||||
}
|
||||
|
||||
# env var -> attribute
|
||||
_ENV_KEYS = {
|
||||
"HINDSIGHT_API_URL": "hindsight_api_url",
|
||||
"HINDSIGHT_API_TOKEN": "hindsight_api_token",
|
||||
"HINDSIGHT_ZED_BANK_ID": "bank_id",
|
||||
}
|
||||
|
||||
|
||||
def load_config(config_file: Optional[Path] = None, env: Optional[dict] = None) -> ZedConfig:
|
||||
"""Load and resolve configuration from file then environment."""
|
||||
cfg = ZedConfig()
|
||||
env = os.environ if env is None else env
|
||||
|
||||
path = config_file if config_file is not None else USER_CONFIG_FILE
|
||||
if path.is_file():
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = {}
|
||||
for key, attr in _FILE_KEYS.items():
|
||||
value = data.get(key)
|
||||
if value:
|
||||
setattr(cfg, attr, str(value))
|
||||
|
||||
for key, attr in _ENV_KEYS.items():
|
||||
value = env.get(key)
|
||||
if value:
|
||||
setattr(cfg, attr, str(value))
|
||||
|
||||
if not cfg.hindsight_api_url:
|
||||
cfg.hindsight_api_url = DEFAULT_HINDSIGHT_API_URL
|
||||
if not cfg.bank_id:
|
||||
cfg.bank_id = DEFAULT_BANK_ID
|
||||
|
||||
return cfg
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Manage Hindsight's recall/retain rule inside Zed's global instructions file.
|
||||
|
||||
Zed includes a global instructions file (``~/.config/zed/AGENTS.md`` on macOS and
|
||||
Linux) in **every** agent conversation. We write a static rule there telling the
|
||||
agent to use the Hindsight MCP tools — recall relevant memory at the start of a
|
||||
task, and retain durable facts as it learns them.
|
||||
|
||||
The rule lives inside a fenced ``<!-- HINDSIGHT:BEGIN -->`` … ``<!-- HINDSIGHT:END -->``
|
||||
block so we can update or remove it without touching the user's own rules in the
|
||||
same file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
BEGIN_MARKER = "<!-- HINDSIGHT:BEGIN -->"
|
||||
END_MARKER = "<!-- HINDSIGHT:END -->"
|
||||
|
||||
# The recall/retain instruction injected into Zed's global rules.
|
||||
RULE_TEXT = (
|
||||
"You have persistent long-term memory through the Hindsight MCP server "
|
||||
"(`recall`, `retain`, and `reflect` tools).\n\n"
|
||||
"- At the start of each task, call `recall` with the user's request to load "
|
||||
"relevant decisions, preferences, and project context before you answer. "
|
||||
"Use what's relevant and ignore the rest.\n"
|
||||
"- When you learn a durable fact — an architectural decision, a user "
|
||||
"preference, a convention, or anything worth remembering across sessions — "
|
||||
"call `retain` to store it.\n"
|
||||
"- Do not mention these memory operations unless the user asks about them."
|
||||
)
|
||||
|
||||
|
||||
def default_rules_path() -> Path:
|
||||
"""Zed's global instructions file (``~/.config/zed/AGENTS.md``)."""
|
||||
return Path.home() / ".config" / "zed" / "AGENTS.md"
|
||||
|
||||
|
||||
def _strip_block(text: str) -> str:
|
||||
"""Remove an existing HINDSIGHT block (and its surrounding blank lines)."""
|
||||
start = text.find(BEGIN_MARKER)
|
||||
if start == -1:
|
||||
return text
|
||||
end = text.find(END_MARKER, start)
|
||||
if end == -1:
|
||||
# Malformed (begin without end) — drop from the marker onward.
|
||||
return text[:start].rstrip() + "\n"
|
||||
end += len(END_MARKER)
|
||||
before = text[:start].rstrip()
|
||||
after = text[end:].lstrip()
|
||||
if before and after:
|
||||
return f"{before}\n\n{after}"
|
||||
return (before or after).rstrip() + ("\n" if (before or after) else "")
|
||||
|
||||
|
||||
def render_block(rule_text: str = RULE_TEXT) -> str:
|
||||
"""Render the fenced HINDSIGHT rule block (no trailing newline)."""
|
||||
return f"{BEGIN_MARKER}\n{rule_text.strip()}\n{END_MARKER}"
|
||||
|
||||
|
||||
def write_rule(path: Path, rule_text: str = RULE_TEXT) -> Path:
|
||||
"""Write/replace Hindsight's rule block in the instructions file at ``path``.
|
||||
|
||||
Preserves any user-authored content and only rewrites our fenced block,
|
||||
placing it at the top so the memory rule leads the instructions.
|
||||
"""
|
||||
existing = path.read_text(encoding="utf-8") if path.is_file() else ""
|
||||
base = _strip_block(existing).rstrip()
|
||||
block = render_block(rule_text)
|
||||
new_text = f"{block}\n\n{base}\n" if base else f"{block}\n"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(new_text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def clear_rule(path: Path) -> Path:
|
||||
"""Remove Hindsight's rule block from the instructions file, if present.
|
||||
|
||||
Leaves the rest of the file intact. If removing the block empties a file that
|
||||
held nothing else, the file is deleted.
|
||||
"""
|
||||
if not path.is_file():
|
||||
return path
|
||||
existing = path.read_text(encoding="utf-8")
|
||||
if BEGIN_MARKER not in existing:
|
||||
return path
|
||||
stripped = _strip_block(existing).strip()
|
||||
if not stripped:
|
||||
path.unlink()
|
||||
return path
|
||||
path.write_text(stripped + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def is_installed(path: Path) -> bool:
|
||||
"""Whether our rule block is present in the instructions file at ``path``."""
|
||||
return path.is_file() and BEGIN_MARKER in path.read_text(encoding="utf-8")
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Wire Hindsight into Zed's MCP ``context_servers`` block.
|
||||
|
||||
Zed has no native HTTP-MCP transport yet, so we connect to Hindsight's HTTP MCP
|
||||
endpoint through the ``mcp-remote`` stdio bridge (run via ``npx``). The server
|
||||
is registered under ``context_servers.hindsight`` in Zed's ``settings.json``.
|
||||
|
||||
Zed's ``settings.json`` is JSONC (it allows comments and trailing commas), which
|
||||
the stdlib JSON parser can't round-trip without dropping the user's comments. So
|
||||
we only edit the file in place when it parses cleanly as strict JSON; otherwise
|
||||
we return the exact snippet for the user to paste, never risking their config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
SERVER_NAME = "hindsight"
|
||||
|
||||
|
||||
def default_settings_path() -> Path:
|
||||
"""Zed's user ``settings.json`` (``~/.config/zed`` on macOS and Linux)."""
|
||||
return Path.home() / ".config" / "zed" / "settings.json"
|
||||
|
||||
|
||||
def mcp_endpoint_url(api_url: str, bank_id: str) -> str:
|
||||
"""The Hindsight MCP endpoint for a bank (bank is the last path segment)."""
|
||||
return f"{api_url.rstrip('/')}/mcp/{bank_id}/"
|
||||
|
||||
|
||||
def build_context_server(api_url: str, api_token: Optional[str], bank_id: str) -> dict[str, Any]:
|
||||
"""Build the ``context_servers.hindsight`` entry for Zed's settings.
|
||||
|
||||
Returns the Zed settings JSON object for the server: an ``mcp-remote`` bridge
|
||||
to the Hindsight MCP endpoint, with a Bearer auth header when a token is set
|
||||
(omitted for an open self-hosted server).
|
||||
"""
|
||||
args = ["-y", "mcp-remote", mcp_endpoint_url(api_url, bank_id)]
|
||||
if api_token:
|
||||
args += ["--header", f"Authorization: Bearer {api_token}"]
|
||||
return {"source": "custom", "command": "npx", "args": args}
|
||||
|
||||
|
||||
def render_snippet(server: dict[str, Any]) -> str:
|
||||
"""Render the settings snippet the user can paste into ``settings.json``."""
|
||||
return json.dumps({"context_servers": {SERVER_NAME: server}}, indent=2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsResult:
|
||||
"""Outcome of editing Zed's settings file.
|
||||
|
||||
``action`` is one of ``created`` (new file written), ``merged`` (our entry
|
||||
written into existing JSON), ``removed`` (our entry deleted), ``unchanged``
|
||||
(nothing to do), or ``manual`` (file is JSONC we won't rewrite — ``snippet``
|
||||
holds what to paste).
|
||||
"""
|
||||
|
||||
action: str
|
||||
path: Path
|
||||
snippet: Optional[str] = None
|
||||
|
||||
|
||||
def _load_strict(path: Path) -> Optional[dict[str, Any]]:
|
||||
"""Parse ``path`` as strict JSON, or return ``None`` if absent/not strict."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def apply_to_settings(path: Path, server: dict[str, Any]) -> SettingsResult:
|
||||
"""Add/update ``context_servers.hindsight`` in Zed's settings at ``path``."""
|
||||
if not path.is_file():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"context_servers": {SERVER_NAME: server}}, indent=2) + "\n", encoding="utf-8")
|
||||
return SettingsResult("created", path)
|
||||
|
||||
data = _load_strict(path)
|
||||
if data is None:
|
||||
# JSONC (comments/trailing commas) or unreadable — don't risk a rewrite.
|
||||
return SettingsResult("manual", path, snippet=render_snippet(server))
|
||||
|
||||
servers = data.get("context_servers")
|
||||
if not isinstance(servers, dict):
|
||||
servers = {}
|
||||
if servers.get(SERVER_NAME) == server:
|
||||
return SettingsResult("unchanged", path)
|
||||
servers[SERVER_NAME] = server
|
||||
data["context_servers"] = servers
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return SettingsResult("merged", path)
|
||||
|
||||
|
||||
def remove_from_settings(path: Path) -> SettingsResult:
|
||||
"""Remove ``context_servers.hindsight`` from Zed's settings at ``path``."""
|
||||
data = _load_strict(path)
|
||||
if data is None:
|
||||
if path.is_file():
|
||||
return SettingsResult("manual", path)
|
||||
return SettingsResult("unchanged", path)
|
||||
|
||||
servers = data.get("context_servers")
|
||||
if not isinstance(servers, dict) or SERVER_NAME not in servers:
|
||||
return SettingsResult("unchanged", path)
|
||||
del servers[SERVER_NAME]
|
||||
if servers:
|
||||
data["context_servers"] = servers
|
||||
else:
|
||||
data.pop("context_servers", None)
|
||||
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
return SettingsResult("removed", path)
|
||||
|
||||
|
||||
def is_installed(path: Path) -> bool:
|
||||
"""Whether our context server is present in Zed's settings at ``path``."""
|
||||
data = _load_strict(path)
|
||||
if data is None:
|
||||
return False
|
||||
servers = data.get("context_servers")
|
||||
return isinstance(servers, dict) and SERVER_NAME in servers
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-zed",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@vectorize-io/hindsight-zed",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"hindsight-zed": "bin/hindsight-zed.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-zed",
|
||||
"version": "0.2.0",
|
||||
"description": "Automatic long-term memory for the Zed editor's AI assistant via Hindsight",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"hindsight-zed": "bin/hindsight-zed.js"
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "echo 'hindsight-zed is plain JS — no build step'",
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"memory",
|
||||
"zed",
|
||||
"agents",
|
||||
"hindsight",
|
||||
"coding"
|
||||
],
|
||||
"author": "Vectorize <support@vectorize.io>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/zed",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vectorize-io/hindsight.git",
|
||||
"directory": "hindsight-integrations/zed"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
[project]
|
||||
name = "hindsight-zed"
|
||||
version = "0.1.0"
|
||||
description = "Automatic long-term memory for the Zed editor's AI assistant via Hindsight"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Vectorize", email = "support@vectorize.io" }
|
||||
]
|
||||
keywords = [
|
||||
"ai",
|
||||
"memory",
|
||||
"zed",
|
||||
"agents",
|
||||
"hindsight",
|
||||
"coding",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
# Configuration-only: the integration writes Zed's MCP settings + a rules file
|
||||
# using the standard library, so it has no runtime dependencies.
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
hindsight-zed = "hindsight_zed.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/vectorize-io/hindsight"
|
||||
Documentation = "https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/zed"
|
||||
Repository = "https://github.com/vectorize-io/hindsight"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hindsight_zed"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"requires_real_llm: end-to-end test that needs live external services (a running Hindsight server and/or real LLM provider keys). Excluded from the deterministic PR-CI bucket via -m 'not requires_real_llm'; run on its own via -m requires_real_llm.",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.2",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
|
||||
"hindsightApiToken": null,
|
||||
"bankId": "zed"
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* CLI for the Hindsight Zed integration.
|
||||
*
|
||||
* `hindsight-zed init` wires Zed's MCP `context_servers` to the Hindsight MCP
|
||||
* endpoint and writes a recall/retain rule into Zed's global instructions file.
|
||||
* After that, Zed's Agent Panel has `recall`/`retain`/`reflect` tools and is told
|
||||
* (via the rule) to use them automatically. There is no background process.
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, join, delimiter } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { VERSION } from "./version.js";
|
||||
import { isFile } from "./fsutil.js";
|
||||
import { USER_CONFIG_FILE, loadConfig } from "./config.js";
|
||||
import {
|
||||
RULE_TEXT,
|
||||
clearRule,
|
||||
defaultRulesPath,
|
||||
writeRule,
|
||||
isInstalled as ruleInstalled,
|
||||
} from "./rulesFile.js";
|
||||
import {
|
||||
applyToSettings,
|
||||
buildContextServer,
|
||||
defaultSettingsPath,
|
||||
removeFromSettings,
|
||||
renderSnippet,
|
||||
isInstalled as serverInstalled,
|
||||
} from "./zedSettings.js";
|
||||
|
||||
/** Apply the MCP server entry and the recall/retain rule (the testable core). */
|
||||
export function buildInstall(config, settingsPath, rulesPath) {
|
||||
const server = buildContextServer(
|
||||
config.hindsightApiUrl,
|
||||
config.hindsightApiToken,
|
||||
config.bankId
|
||||
);
|
||||
const settings = applyToSettings(settingsPath, server);
|
||||
writeRule(rulesPath);
|
||||
return { settings, rulesPath };
|
||||
}
|
||||
|
||||
function configPath(values) {
|
||||
return values["config-path"] || USER_CONFIG_FILE;
|
||||
}
|
||||
|
||||
/** Config from file/env, overridden by any explicitly-passed CLI flags. */
|
||||
function resolveConfig(values) {
|
||||
const cfg = loadConfig({ configFile: configPath(values) });
|
||||
if (values["api-url"]) cfg.hindsightApiUrl = values["api-url"];
|
||||
if (values["api-token"]) cfg.hindsightApiToken = values["api-token"];
|
||||
if (values["bank-id"]) cfg.bankId = values["bank-id"];
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/** Persist the resolved connection settings so re-runs remember them. */
|
||||
function scaffoldConfig(cfg, path) {
|
||||
if (isFile(path)) return;
|
||||
const data = { hindsightApiUrl: cfg.hindsightApiUrl, bankId: cfg.bankId };
|
||||
if (cfg.hindsightApiToken) data.hindsightApiToken = cfg.hindsightApiToken;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
||||
}
|
||||
|
||||
/** Whether `cmd` resolves on PATH (mirrors Python's `shutil.which`). */
|
||||
function commandExists(cmd) {
|
||||
const dirs = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
||||
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
||||
for (const dir of dirs) {
|
||||
for (const ext of exts) {
|
||||
const candidate = join(dir, cmd + ext);
|
||||
try {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) return true;
|
||||
} catch {
|
||||
// ignore unreadable PATH entries
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cmdInit(values) {
|
||||
const cfg = resolveConfig(values);
|
||||
const settingsPath = values["settings-path"] || defaultSettingsPath();
|
||||
const rulesPath = values["rules-path"] || defaultRulesPath();
|
||||
const server = buildContextServer(cfg.hindsightApiUrl, cfg.hindsightApiToken, cfg.bankId);
|
||||
|
||||
if (values["print-only"]) {
|
||||
console.log("Add this to your Zed settings.json:\n");
|
||||
console.log(renderSnippet(server));
|
||||
console.log("\nAnd add this rule to ~/.config/zed/AGENTS.md:\n");
|
||||
console.log(RULE_TEXT);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log("Setting up Hindsight for Zed ...");
|
||||
scaffoldConfig(cfg, configPath(values));
|
||||
const outcome = buildInstall(cfg, settingsPath, rulesPath);
|
||||
|
||||
if (outcome.settings.action === "manual") {
|
||||
console.log(` Your ${outcome.settings.path} has comments, so I won't rewrite it.`);
|
||||
console.log(" Add this `context_servers` entry yourself:\n");
|
||||
console.log(renderSnippet(server));
|
||||
} else {
|
||||
const verb = { created: "Created", merged: "Updated", unchanged: "Already configured in" }[
|
||||
outcome.settings.action
|
||||
];
|
||||
console.log(
|
||||
` ${verb} ${outcome.settings.path} (MCP server: hindsight → bank '${cfg.bankId}')`
|
||||
);
|
||||
}
|
||||
console.log(` Wrote recall/retain rule to ${outcome.rulesPath}`);
|
||||
|
||||
if (!commandExists("npx")) {
|
||||
console.log("\n warning: `npx` (Node.js) was not found on PATH. Zed runs the MCP");
|
||||
console.log(" bridge via `npx mcp-remote`, so install Node.js for the server to start.");
|
||||
}
|
||||
|
||||
console.log("\nDone. Restart Zed, open the Agent Panel, and the `hindsight` MCP server");
|
||||
console.log("should show a green dot. Memory recall/retain then happen automatically.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdStatus(values) {
|
||||
const settingsPath = values["settings-path"] || defaultSettingsPath();
|
||||
const rulesPath = values["rules-path"] || defaultRulesPath();
|
||||
console.log(
|
||||
`MCP server in ${settingsPath}: ${serverInstalled(settingsPath) ? "installed" : "not installed"}`
|
||||
);
|
||||
console.log(
|
||||
`Recall/retain rule in ${rulesPath}: ${ruleInstalled(rulesPath) ? "installed" : "not installed"}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cmdUninstall(values) {
|
||||
const settingsPath = values["settings-path"] || defaultSettingsPath();
|
||||
const rulesPath = values["rules-path"] || defaultRulesPath();
|
||||
const result = removeFromSettings(settingsPath);
|
||||
if (result.action === "manual") {
|
||||
console.log(
|
||||
` ${settingsPath} has comments — remove the \`hindsight\` context_servers entry yourself.`
|
||||
);
|
||||
} else if (result.action === "removed") {
|
||||
console.log(` Removed the hindsight MCP server from ${settingsPath}`);
|
||||
} else {
|
||||
console.log(` No hindsight MCP server found in ${settingsPath}`);
|
||||
}
|
||||
clearRule(rulesPath);
|
||||
console.log(` Removed the recall/retain rule from ${rulesPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`hindsight-zed — Hindsight memory for Zed (via MCP)
|
||||
|
||||
Usage: hindsight-zed <command> [options]
|
||||
|
||||
Commands:
|
||||
init Configure Zed's MCP server + recall/retain rule
|
||||
status Show whether the MCP server + rule are configured
|
||||
uninstall Remove the MCP server + rule
|
||||
|
||||
init options:
|
||||
--api-url <url> Hindsight API URL (default: cloud)
|
||||
--api-token <token> Hindsight API token (for Cloud)
|
||||
--bank-id <id> Memory bank for the MCP server (default: zed)
|
||||
--print-only Print the config to add manually; write nothing
|
||||
|
||||
--version Print version`);
|
||||
}
|
||||
|
||||
const OPTIONS = {
|
||||
version: { type: "boolean" },
|
||||
help: { type: "boolean" },
|
||||
"api-url": { type: "string" },
|
||||
"api-token": { type: "string" },
|
||||
"bank-id": { type: "string" },
|
||||
"print-only": { type: "boolean" },
|
||||
// Hidden overrides used by tests and advanced setups.
|
||||
"settings-path": { type: "string" },
|
||||
"rules-path": { type: "string" },
|
||||
"config-path": { type: "string" },
|
||||
};
|
||||
|
||||
export function main(argv = process.argv.slice(2)) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseArgs({ args: argv, allowPositionals: true, options: OPTIONS });
|
||||
} catch (err) {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const { values, positionals } = parsed;
|
||||
if (values.version) {
|
||||
console.log(`hindsight-zed ${VERSION}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const command = positionals[0];
|
||||
if (!command || values.help) {
|
||||
printHelp();
|
||||
return command ? 0 : 1;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case "init":
|
||||
return cmdInit(values);
|
||||
case "status":
|
||||
return cmdStatus(values);
|
||||
case "uninstall":
|
||||
return cmdUninstall(values);
|
||||
default:
|
||||
process.stderr.write(`Unknown command: ${command}\n`);
|
||||
printHelp();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Configuration for the Hindsight Zed integration.
|
||||
*
|
||||
* Settings layer (later wins): built-in defaults -> ~/.hindsight/zed.json ->
|
||||
* environment variables. Resolved into a plain config object.
|
||||
*
|
||||
* The integration is configuration-only: it wires Zed's MCP `context_servers` to
|
||||
* the Hindsight MCP endpoint and writes a recall/retain rule into Zed's global
|
||||
* instructions file. Memory operations happen through the MCP server at runtime,
|
||||
* so there is no daemon or direct API client here.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isFile } from "./fsutil.js";
|
||||
|
||||
// Cross-integration cloud-default convention.
|
||||
export const DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io";
|
||||
export const DEFAULT_BANK_ID = "zed";
|
||||
|
||||
export const USER_CONFIG_FILE = join(homedir(), ".hindsight", "zed.json");
|
||||
|
||||
// user-config file key -> config attribute
|
||||
const FILE_KEYS = {
|
||||
hindsightApiUrl: "hindsightApiUrl",
|
||||
hindsightApiToken: "hindsightApiToken",
|
||||
bankId: "bankId",
|
||||
};
|
||||
|
||||
// env var -> config attribute
|
||||
const ENV_KEYS = {
|
||||
HINDSIGHT_API_URL: "hindsightApiUrl",
|
||||
HINDSIGHT_API_TOKEN: "hindsightApiToken",
|
||||
HINDSIGHT_ZED_BANK_ID: "bankId",
|
||||
};
|
||||
|
||||
/** Load and resolve configuration from file then environment. */
|
||||
export function loadConfig({ configFile = USER_CONFIG_FILE, env = process.env } = {}) {
|
||||
const cfg = {
|
||||
hindsightApiUrl: DEFAULT_HINDSIGHT_API_URL,
|
||||
hindsightApiToken: null,
|
||||
bankId: DEFAULT_BANK_ID,
|
||||
};
|
||||
|
||||
if (isFile(configFile)) {
|
||||
let data = {};
|
||||
try {
|
||||
data = JSON.parse(readFileSync(configFile, "utf-8"));
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
for (const [key, attr] of Object.entries(FILE_KEYS)) {
|
||||
const value = data[key];
|
||||
if (value) cfg[attr] = String(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, attr] of Object.entries(ENV_KEYS)) {
|
||||
const value = env[key];
|
||||
if (value) cfg[attr] = String(value);
|
||||
}
|
||||
|
||||
if (!cfg.hindsightApiUrl) cfg.hindsightApiUrl = DEFAULT_HINDSIGHT_API_URL;
|
||||
if (!cfg.bankId) cfg.bankId = DEFAULT_BANK_ID;
|
||||
|
||||
return cfg;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
|
||||
/** Whether `p` exists and is a regular file (mirrors Python's `Path.is_file()`). */
|
||||
export function isFile(p) {
|
||||
try {
|
||||
return existsSync(p) && statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Hindsight memory integration for the Zed editor. */
|
||||
export { VERSION } from "./version.js";
|
||||
export * as config from "./config.js";
|
||||
export * as rulesFile from "./rulesFile.js";
|
||||
export * as zedSettings from "./zedSettings.js";
|
||||
export { main, buildInstall } from "./cli.js";
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Manage Hindsight's recall/retain rule inside Zed's global instructions file.
|
||||
*
|
||||
* Zed includes a global instructions file (`~/.config/zed/AGENTS.md` on macOS and
|
||||
* Linux) in *every* agent conversation. We write a static rule there telling the
|
||||
* agent to use the Hindsight MCP tools — recall relevant memory at the start of a
|
||||
* task, and retain durable facts as it learns them.
|
||||
*
|
||||
* The rule lives inside a fenced `<!-- HINDSIGHT:BEGIN -->` … `<!-- HINDSIGHT:END -->`
|
||||
* block so we can update or remove it without touching the user's own rules in the
|
||||
* same file.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
import { isFile } from "./fsutil.js";
|
||||
|
||||
export const BEGIN_MARKER = "<!-- HINDSIGHT:BEGIN -->";
|
||||
export const END_MARKER = "<!-- HINDSIGHT:END -->";
|
||||
|
||||
// The recall/retain instruction injected into Zed's global rules.
|
||||
export const RULE_TEXT =
|
||||
"You have persistent long-term memory through the Hindsight MCP server " +
|
||||
"(`recall`, `retain`, and `reflect` tools).\n\n" +
|
||||
"- At the start of each task, call `recall` with the user's request to load " +
|
||||
"relevant decisions, preferences, and project context before you answer. " +
|
||||
"Use what's relevant and ignore the rest.\n" +
|
||||
"- When you learn a durable fact — an architectural decision, a user " +
|
||||
"preference, a convention, or anything worth remembering across sessions — " +
|
||||
"call `retain` to store it.\n" +
|
||||
"- Do not mention these memory operations unless the user asks about them.";
|
||||
|
||||
/** Zed's global instructions file (`~/.config/zed/AGENTS.md`). */
|
||||
export function defaultRulesPath() {
|
||||
return join(homedir(), ".config", "zed", "AGENTS.md");
|
||||
}
|
||||
|
||||
/** Remove an existing HINDSIGHT block (and its surrounding blank lines). */
|
||||
function stripBlock(text) {
|
||||
const start = text.indexOf(BEGIN_MARKER);
|
||||
if (start === -1) return text;
|
||||
let end = text.indexOf(END_MARKER, start);
|
||||
if (end === -1) {
|
||||
// Malformed (begin without end) — drop from the marker onward.
|
||||
return text.slice(0, start).replace(/\s+$/, "") + "\n";
|
||||
}
|
||||
end += END_MARKER.length;
|
||||
const before = text.slice(0, start).replace(/\s+$/, ""); // rstrip
|
||||
const after = text.slice(end).replace(/^\s+/, ""); // lstrip
|
||||
if (before && after) return `${before}\n\n${after}`;
|
||||
const remainder = before || after;
|
||||
return remainder.replace(/\s+$/, "") + (remainder ? "\n" : "");
|
||||
}
|
||||
|
||||
/** Render the fenced HINDSIGHT rule block (no trailing newline). */
|
||||
export function renderBlock(ruleText = RULE_TEXT) {
|
||||
return `${BEGIN_MARKER}\n${ruleText.trim()}\n${END_MARKER}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write/replace Hindsight's rule block in the instructions file at `path`.
|
||||
*
|
||||
* Preserves any user-authored content and only rewrites our fenced block,
|
||||
* placing it at the top so the memory rule leads the instructions.
|
||||
*/
|
||||
export function writeRule(path, ruleText = RULE_TEXT) {
|
||||
const existing = isFile(path) ? readFileSync(path, "utf-8") : "";
|
||||
const base = stripBlock(existing).replace(/\s+$/, "");
|
||||
const block = renderBlock(ruleText);
|
||||
const newText = base ? `${block}\n\n${base}\n` : `${block}\n`;
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, newText, "utf-8");
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Hindsight's rule block from the instructions file, if present.
|
||||
*
|
||||
* Leaves the rest of the file intact. If removing the block empties a file that
|
||||
* held nothing else, the file is deleted.
|
||||
*/
|
||||
export function clearRule(path) {
|
||||
if (!isFile(path)) return path;
|
||||
const existing = readFileSync(path, "utf-8");
|
||||
if (!existing.includes(BEGIN_MARKER)) return path;
|
||||
const stripped = stripBlock(existing).trim();
|
||||
if (!stripped) {
|
||||
unlinkSync(path);
|
||||
return path;
|
||||
}
|
||||
writeFileSync(path, stripped + "\n", "utf-8");
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Whether our rule block is present in the instructions file at `path`. */
|
||||
export function isInstalled(path) {
|
||||
return isFile(path) && readFileSync(path, "utf-8").includes(BEGIN_MARKER);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const VERSION = "0.2.0";
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Wire Hindsight into Zed's MCP `context_servers` block.
|
||||
*
|
||||
* Zed has no native HTTP-MCP transport yet, so we connect to Hindsight's HTTP MCP
|
||||
* endpoint through the `mcp-remote` stdio bridge (run via `npx`). The server is
|
||||
* registered under `context_servers.hindsight` in Zed's `settings.json`.
|
||||
*
|
||||
* Zed's `settings.json` is JSONC (it allows comments and trailing commas), which
|
||||
* a strict JSON parser can't round-trip without dropping the user's comments. So
|
||||
* we only edit the file in place when it parses cleanly as strict JSON; otherwise
|
||||
* we return the exact snippet for the user to paste, never risking their config.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import { isFile } from "./fsutil.js";
|
||||
|
||||
export const SERVER_NAME = "hindsight";
|
||||
|
||||
/** Zed's user `settings.json` (`~/.config/zed` on macOS and Linux). */
|
||||
export function defaultSettingsPath() {
|
||||
return join(homedir(), ".config", "zed", "settings.json");
|
||||
}
|
||||
|
||||
/** The Hindsight MCP endpoint for a bank (bank is the last path segment). */
|
||||
export function mcpEndpointUrl(apiUrl, bankId) {
|
||||
return `${apiUrl.replace(/\/+$/, "")}/mcp/${bankId}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `context_servers.hindsight` entry for Zed's settings.
|
||||
*
|
||||
* Returns the Zed settings JSON object for the server: an `mcp-remote` bridge to
|
||||
* the Hindsight MCP endpoint, with a Bearer auth header when a token is set
|
||||
* (omitted for an open self-hosted server).
|
||||
*/
|
||||
export function buildContextServer(apiUrl, apiToken, bankId) {
|
||||
const args = ["-y", "mcp-remote", mcpEndpointUrl(apiUrl, bankId)];
|
||||
if (apiToken) {
|
||||
args.push("--header", `Authorization: Bearer ${apiToken}`);
|
||||
}
|
||||
return { source: "custom", command: "npx", args };
|
||||
}
|
||||
|
||||
/** Render the settings snippet the user can paste into `settings.json`. */
|
||||
export function renderSnippet(server) {
|
||||
return JSON.stringify({ context_servers: { [SERVER_NAME]: server } }, null, 2);
|
||||
}
|
||||
|
||||
/** Parse `path` as strict JSON, or return `null` if absent/not strict. */
|
||||
function loadStrict(path) {
|
||||
if (!isFile(path)) return null;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(path, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return data && typeof data === "object" && !Array.isArray(data) ? data : null;
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add/update `context_servers.hindsight` in Zed's settings at `path`.
|
||||
*
|
||||
* Returns `{ action, path, snippet? }` where `action` is one of `created`,
|
||||
* `merged`, `unchanged`, or `manual` (JSONC we won't rewrite — `snippet` holds
|
||||
* what to paste).
|
||||
*/
|
||||
export function applyToSettings(path, server) {
|
||||
if (!isFile(path)) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ context_servers: { [SERVER_NAME]: server } }, null, 2) + "\n",
|
||||
"utf-8"
|
||||
);
|
||||
return { action: "created", path };
|
||||
}
|
||||
|
||||
const data = loadStrict(path);
|
||||
if (data === null) {
|
||||
// JSONC (comments/trailing commas) or unreadable — don't risk a rewrite.
|
||||
return { action: "manual", path, snippet: renderSnippet(server) };
|
||||
}
|
||||
|
||||
let servers = data.context_servers;
|
||||
if (!isPlainObject(servers)) servers = {};
|
||||
if (isDeepStrictEqual(servers[SERVER_NAME], server)) {
|
||||
return { action: "unchanged", path };
|
||||
}
|
||||
servers[SERVER_NAME] = server;
|
||||
data.context_servers = servers;
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
||||
return { action: "merged", path };
|
||||
}
|
||||
|
||||
/** Remove `context_servers.hindsight` from Zed's settings at `path`. */
|
||||
export function removeFromSettings(path) {
|
||||
const data = loadStrict(path);
|
||||
if (data === null) {
|
||||
if (isFile(path)) return { action: "manual", path };
|
||||
return { action: "unchanged", path };
|
||||
}
|
||||
|
||||
const servers = data.context_servers;
|
||||
if (!isPlainObject(servers) || !(SERVER_NAME in servers)) {
|
||||
return { action: "unchanged", path };
|
||||
}
|
||||
delete servers[SERVER_NAME];
|
||||
if (Object.keys(servers).length > 0) {
|
||||
data.context_servers = servers;
|
||||
} else {
|
||||
delete data.context_servers;
|
||||
}
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
||||
return { action: "removed", path };
|
||||
}
|
||||
|
||||
/** Whether our context server is present in Zed's settings at `path`. */
|
||||
export function isInstalled(path) {
|
||||
const data = loadStrict(path);
|
||||
if (data === null) return false;
|
||||
const servers = data.context_servers;
|
||||
return isPlainObject(servers) && SERVER_NAME in servers;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { main } from "../src/cli.js";
|
||||
import { BEGIN_MARKER } from "../src/rulesFile.js";
|
||||
import { SERVER_NAME } from "../src/zedSettings.js";
|
||||
|
||||
function tmp() {
|
||||
return mkdtempSync(join(tmpdir(), "hz-cli-"));
|
||||
}
|
||||
|
||||
/** Run main() while capturing stdout. */
|
||||
function run(argv) {
|
||||
const lines = [];
|
||||
const orig = console.log;
|
||||
console.log = (...args) => lines.push(args.join(" "));
|
||||
let code;
|
||||
try {
|
||||
code = main(argv);
|
||||
} finally {
|
||||
console.log = orig;
|
||||
}
|
||||
return { code, out: lines.join("\n") };
|
||||
}
|
||||
|
||||
function paths(dir) {
|
||||
return [
|
||||
"--settings-path",
|
||||
join(dir, "settings.json"),
|
||||
"--rules-path",
|
||||
join(dir, "AGENTS.md"),
|
||||
"--config-path",
|
||||
join(dir, "zed.json"),
|
||||
];
|
||||
}
|
||||
|
||||
test("init writes settings, rule, and scaffolds config", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { code } = run(["init", "--api-token", "tok", "--bank-id", "proj", ...paths(dir)]);
|
||||
assert.equal(code, 0);
|
||||
|
||||
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf-8"));
|
||||
const server = settings.context_servers[SERVER_NAME];
|
||||
assert.equal(server.command, "npx");
|
||||
assert.ok(server.args.includes("mcp-remote"));
|
||||
assert.ok(server.args.some((a) => a.includes("/mcp/proj/")));
|
||||
assert.ok(server.args.includes("Authorization: Bearer tok"));
|
||||
|
||||
assert.ok(readFileSync(join(dir, "AGENTS.md"), "utf-8").includes(BEGIN_MARKER));
|
||||
|
||||
const cfg = JSON.parse(readFileSync(join(dir, "zed.json"), "utf-8"));
|
||||
assert.equal(cfg.bankId, "proj");
|
||||
assert.equal(cfg.hindsightApiToken, "tok");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("init --print-only writes nothing", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const { code, out } = run(["init", "--print-only", "--bank-id", "zed", ...paths(dir)]);
|
||||
assert.equal(code, 0);
|
||||
assert.ok(out.includes("mcp-remote"));
|
||||
assert.ok(out.includes("recall"));
|
||||
assert.equal(existsSync(join(dir, "settings.json")), false);
|
||||
assert.equal(existsSync(join(dir, "AGENTS.md")), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("status reflects installed state", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
let res = run(["status", ...paths(dir)]);
|
||||
assert.ok(res.out.includes("not installed"));
|
||||
run(["init", "--api-token", "tok", ...paths(dir)]);
|
||||
res = run(["status", ...paths(dir)]);
|
||||
assert.ok(res.out.includes("MCP server"));
|
||||
assert.ok(!res.out.includes("not installed"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uninstall removes the server and the rule", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
run(["init", "--api-token", "tok", ...paths(dir)]);
|
||||
const { code } = run(["uninstall", ...paths(dir)]);
|
||||
assert.equal(code, 0);
|
||||
const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf-8"));
|
||||
assert.ok(!("context_servers" in settings));
|
||||
assert.equal(existsSync(join(dir, "AGENTS.md")), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("init on a JSONC settings file prints the manual snippet, leaves it untouched", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const settingsPath = join(dir, "settings.json");
|
||||
writeFileSync(settingsPath, '{\n // keep me\n "theme": "one"\n}\n');
|
||||
const { out } = run(["init", "--api-token", "tok", ...paths(dir)]);
|
||||
assert.ok(out.includes("has comments"));
|
||||
assert.ok(out.includes("mcp-remote"));
|
||||
assert.ok(readFileSync(settingsPath, "utf-8").includes("// keep me"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("no command prints help and returns non-zero", () => {
|
||||
const { code, out } = run([]);
|
||||
assert.equal(code, 1);
|
||||
assert.ok(out.includes("Usage"));
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { loadConfig, DEFAULT_HINDSIGHT_API_URL, DEFAULT_BANK_ID } from "../src/config.js";
|
||||
|
||||
function tmp() {
|
||||
return mkdtempSync(join(tmpdir(), "hz-config-"));
|
||||
}
|
||||
|
||||
test("defaults when no file and empty env", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const cfg = loadConfig({ configFile: join(dir, "missing.json"), env: {} });
|
||||
assert.equal(cfg.hindsightApiUrl, DEFAULT_HINDSIGHT_API_URL);
|
||||
assert.equal(cfg.hindsightApiToken, null);
|
||||
assert.equal(cfg.bankId, DEFAULT_BANK_ID);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("reads values from the config file", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const file = join(dir, "zed.json");
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
hindsightApiUrl: "http://localhost:8888",
|
||||
hindsightApiToken: "tok",
|
||||
bankId: "proj",
|
||||
})
|
||||
);
|
||||
const cfg = loadConfig({ configFile: file, env: {} });
|
||||
assert.equal(cfg.hindsightApiUrl, "http://localhost:8888");
|
||||
assert.equal(cfg.hindsightApiToken, "tok");
|
||||
assert.equal(cfg.bankId, "proj");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("environment variables win over the file", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const file = join(dir, "zed.json");
|
||||
writeFileSync(file, JSON.stringify({ hindsightApiUrl: "http://file", bankId: "fromfile" }));
|
||||
const cfg = loadConfig({
|
||||
configFile: file,
|
||||
env: {
|
||||
HINDSIGHT_API_URL: "http://env",
|
||||
HINDSIGHT_API_TOKEN: "envtok",
|
||||
HINDSIGHT_ZED_BANK_ID: "fromenv",
|
||||
},
|
||||
});
|
||||
assert.equal(cfg.hindsightApiUrl, "http://env");
|
||||
assert.equal(cfg.hindsightApiToken, "envtok");
|
||||
assert.equal(cfg.bankId, "fromenv");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed config file falls back to defaults", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const file = join(dir, "zed.json");
|
||||
writeFileSync(file, "{ not json");
|
||||
const cfg = loadConfig({ configFile: file, env: {} });
|
||||
assert.equal(cfg.hindsightApiUrl, DEFAULT_HINDSIGHT_API_URL);
|
||||
assert.equal(cfg.bankId, DEFAULT_BANK_ID);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
BEGIN_MARKER,
|
||||
END_MARKER,
|
||||
RULE_TEXT,
|
||||
writeRule,
|
||||
clearRule,
|
||||
isInstalled,
|
||||
} from "../src/rulesFile.js";
|
||||
|
||||
function tmpPath(name = "AGENTS.md") {
|
||||
return join(mkdtempSync(join(tmpdir(), "hz-rules-")), name);
|
||||
}
|
||||
|
||||
test("write creates the file with the fenced block", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
writeRule(p);
|
||||
const text = readFileSync(p, "utf-8");
|
||||
assert.ok(text.includes(BEGIN_MARKER));
|
||||
assert.ok(text.includes(END_MARKER));
|
||||
assert.ok(text.includes("recall"));
|
||||
assert.ok(isInstalled(p));
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("write preserves user content and puts our block on top", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
writeFileSync(p, "# My rules\n\nBe concise.\n");
|
||||
writeRule(p);
|
||||
const text = readFileSync(p, "utf-8");
|
||||
assert.ok(text.startsWith(BEGIN_MARKER));
|
||||
assert.ok(text.includes("# My rules"));
|
||||
assert.ok(text.includes("Be concise."));
|
||||
// Exactly one block after a repeated write.
|
||||
writeRule(p);
|
||||
const text2 = readFileSync(p, "utf-8");
|
||||
assert.equal(text2.split(BEGIN_MARKER).length - 1, 1);
|
||||
assert.ok(text2.includes("# My rules"));
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clear removes our block but keeps user content", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
writeFileSync(p, "# My rules\n\nBe concise.\n");
|
||||
writeRule(p);
|
||||
clearRule(p);
|
||||
const text = readFileSync(p, "utf-8");
|
||||
assert.ok(!text.includes(BEGIN_MARKER));
|
||||
assert.ok(text.includes("# My rules"));
|
||||
assert.ok(!isInstalled(p));
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clear deletes the file if it held only our block", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
writeRule(p);
|
||||
clearRule(p);
|
||||
assert.equal(existsSync(p), false);
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom rule text round-trips through the block", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
writeRule(p, "CUSTOM RULE");
|
||||
const text = readFileSync(p, "utf-8");
|
||||
assert.ok(text.includes("CUSTOM RULE"));
|
||||
assert.ok(!text.includes(RULE_TEXT));
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
SERVER_NAME,
|
||||
mcpEndpointUrl,
|
||||
buildContextServer,
|
||||
applyToSettings,
|
||||
removeFromSettings,
|
||||
isInstalled,
|
||||
} from "../src/zedSettings.js";
|
||||
|
||||
function tmpPath(name = "settings.json") {
|
||||
return join(mkdtempSync(join(tmpdir(), "hz-settings-")), name);
|
||||
}
|
||||
|
||||
test("mcpEndpointUrl builds a bank-scoped MCP path", () => {
|
||||
assert.equal(
|
||||
mcpEndpointUrl("https://api.hindsight.vectorize.io", "zed"),
|
||||
"https://api.hindsight.vectorize.io/mcp/zed/"
|
||||
);
|
||||
// Trailing slash on the base URL is normalized.
|
||||
assert.equal(mcpEndpointUrl("http://localhost:8888/", "proj"), "http://localhost:8888/mcp/proj/");
|
||||
});
|
||||
|
||||
test("buildContextServer runs npx mcp-remote, with a header only when a token is set", () => {
|
||||
const withToken = buildContextServer("https://api.hindsight.vectorize.io", "secret", "zed");
|
||||
assert.deepEqual(withToken, {
|
||||
source: "custom",
|
||||
command: "npx",
|
||||
args: [
|
||||
"-y",
|
||||
"mcp-remote",
|
||||
"https://api.hindsight.vectorize.io/mcp/zed/",
|
||||
"--header",
|
||||
"Authorization: Bearer secret",
|
||||
],
|
||||
});
|
||||
|
||||
const noToken = buildContextServer("http://localhost:8888", null, "zed");
|
||||
assert.deepEqual(noToken.args, ["-y", "mcp-remote", "http://localhost:8888/mcp/zed/"]);
|
||||
});
|
||||
|
||||
test("apply creates, then reports unchanged, then merges", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
const server = buildContextServer("https://api.hindsight.vectorize.io", "tok", "zed");
|
||||
|
||||
const created = applyToSettings(p, server);
|
||||
assert.equal(created.action, "created");
|
||||
assert.ok(isInstalled(p));
|
||||
|
||||
const again = applyToSettings(p, server);
|
||||
assert.equal(again.action, "unchanged");
|
||||
|
||||
const server2 = buildContextServer("https://api.hindsight.vectorize.io", "tok", "other-bank");
|
||||
const merged = applyToSettings(p, server2);
|
||||
assert.equal(merged.action, "merged");
|
||||
const data = JSON.parse(readFileSync(p, "utf-8"));
|
||||
assert.ok(
|
||||
data.context_servers[SERVER_NAME].args.includes(
|
||||
"https://api.hindsight.vectorize.io/mcp/other-bank/"
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("apply merges into existing settings without clobbering other keys", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
writeFileSync(
|
||||
p,
|
||||
JSON.stringify({ theme: "dark", context_servers: { other: { command: "x" } } }, null, 2)
|
||||
);
|
||||
const server = buildContextServer("https://api.hindsight.vectorize.io", "tok", "zed");
|
||||
const res = applyToSettings(p, server);
|
||||
assert.equal(res.action, "merged");
|
||||
const data = JSON.parse(readFileSync(p, "utf-8"));
|
||||
assert.equal(data.theme, "dark");
|
||||
assert.ok(data.context_servers.other);
|
||||
assert.ok(data.context_servers[SERVER_NAME]);
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("JSONC (comments) settings are never rewritten — manual snippet returned", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
writeFileSync(p, '{\n // user comment\n "theme": "dark",\n}\n');
|
||||
const server = buildContextServer("https://api.hindsight.vectorize.io", "tok", "zed");
|
||||
const res = applyToSettings(p, server);
|
||||
assert.equal(res.action, "manual");
|
||||
assert.ok(res.snippet.includes("mcp-remote"));
|
||||
// File is untouched.
|
||||
assert.ok(readFileSync(p, "utf-8").includes("// user comment"));
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("remove deletes our entry and drops an empty context_servers", () => {
|
||||
const p = tmpPath();
|
||||
try {
|
||||
const server = buildContextServer("https://api.hindsight.vectorize.io", "tok", "zed");
|
||||
applyToSettings(p, server);
|
||||
const res = removeFromSettings(p);
|
||||
assert.equal(res.action, "removed");
|
||||
const data = JSON.parse(readFileSync(p, "utf-8"));
|
||||
assert.ok(!("context_servers" in data));
|
||||
assert.equal(isInstalled(p), false);
|
||||
} finally {
|
||||
rmSync(join(p, ".."), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Tests for the CLI (init/status/uninstall over settings + rule files)."""
|
||||
|
||||
import json
|
||||
|
||||
from hindsight_zed.cli import build_install, main
|
||||
from hindsight_zed.config import ZedConfig
|
||||
from hindsight_zed.zed_settings import SERVER_NAME, is_installed as server_installed
|
||||
|
||||
|
||||
class TestBuildInstall:
|
||||
def test_writes_settings_and_rule(self, tmp_path):
|
||||
settings = tmp_path / "settings.json"
|
||||
rules = tmp_path / "AGENTS.md"
|
||||
cfg = ZedConfig(hindsight_api_url="https://api.hindsight.vectorize.io", hindsight_api_token="k", bank_id="proj")
|
||||
outcome = build_install(cfg, settings, rules)
|
||||
|
||||
assert outcome.settings.action == "created"
|
||||
server = json.loads(settings.read_text())["context_servers"][SERVER_NAME]
|
||||
assert "https://api.hindsight.vectorize.io/mcp/proj/" in server["args"]
|
||||
assert "Authorization: Bearer k" in server["args"]
|
||||
assert rules.read_text().count("HINDSIGHT:BEGIN") == 1
|
||||
|
||||
|
||||
class TestMainCommands:
|
||||
def test_init_then_status_then_uninstall(self, tmp_path, capsys):
|
||||
settings = str(tmp_path / "settings.json")
|
||||
rules = str(tmp_path / "AGENTS.md")
|
||||
config = str(tmp_path / "zed.json")
|
||||
common = ["--settings-path", settings, "--rules-path", rules, "--config-path", config]
|
||||
|
||||
rc = main(["init", "--api-url", "http://localhost:8888", "--bank-id", "b", *common])
|
||||
assert rc == 0
|
||||
assert server_installed(tmp_path / "settings.json")
|
||||
|
||||
main(["status", *common])
|
||||
out = capsys.readouterr().out
|
||||
assert "installed" in out
|
||||
|
||||
main(["uninstall", *common])
|
||||
# settings entry + rule both gone
|
||||
assert not server_installed(tmp_path / "settings.json")
|
||||
assert not (tmp_path / "AGENTS.md").exists()
|
||||
|
||||
def test_print_only_writes_nothing(self, tmp_path, capsys):
|
||||
settings = tmp_path / "settings.json"
|
||||
rules = tmp_path / "AGENTS.md"
|
||||
rc = main(
|
||||
[
|
||||
"init",
|
||||
"--print-only",
|
||||
"--api-url",
|
||||
"http://localhost:8888",
|
||||
"--settings-path",
|
||||
str(settings),
|
||||
"--rules-path",
|
||||
str(rules),
|
||||
"--config-path",
|
||||
str(tmp_path / "zed.json"),
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
assert not settings.exists()
|
||||
assert not rules.exists()
|
||||
assert "context_servers" in capsys.readouterr().out
|
||||
|
||||
def test_no_command_prints_help_and_returns_1(self, capsys):
|
||||
assert main([]) == 1
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Tests for config loading/merging."""
|
||||
|
||||
import json
|
||||
|
||||
from hindsight_zed.config import DEFAULT_BANK_ID, DEFAULT_HINDSIGHT_API_URL, load_config
|
||||
|
||||
|
||||
def test_defaults(tmp_path):
|
||||
cfg = load_config(config_file=tmp_path / "missing.json", env={})
|
||||
assert cfg.hindsight_api_url == DEFAULT_HINDSIGHT_API_URL
|
||||
assert cfg.hindsight_api_token is None
|
||||
assert cfg.bank_id == DEFAULT_BANK_ID
|
||||
|
||||
|
||||
def test_file_values(tmp_path):
|
||||
p = tmp_path / "zed.json"
|
||||
p.write_text(json.dumps({"hindsightApiUrl": "http://localhost:8888", "hindsightApiToken": "t", "bankId": "proj"}))
|
||||
cfg = load_config(config_file=p, env={})
|
||||
assert cfg.hindsight_api_url == "http://localhost:8888"
|
||||
assert cfg.hindsight_api_token == "t"
|
||||
assert cfg.bank_id == "proj"
|
||||
|
||||
|
||||
def test_env_overrides_file(tmp_path):
|
||||
p = tmp_path / "zed.json"
|
||||
p.write_text(json.dumps({"hindsightApiUrl": "http://file:8888", "bankId": "from-file"}))
|
||||
env = {"HINDSIGHT_API_URL": "http://env:9999", "HINDSIGHT_ZED_BANK_ID": "from-env", "HINDSIGHT_API_TOKEN": "k"}
|
||||
cfg = load_config(config_file=p, env=env)
|
||||
assert cfg.hindsight_api_url == "http://env:9999"
|
||||
assert cfg.bank_id == "from-env"
|
||||
assert cfg.hindsight_api_token == "k"
|
||||
|
||||
|
||||
def test_malformed_file_falls_back_to_defaults(tmp_path):
|
||||
p = tmp_path / "zed.json"
|
||||
p.write_text("{ not valid json")
|
||||
cfg = load_config(config_file=p, env={})
|
||||
assert cfg.hindsight_api_url == DEFAULT_HINDSIGHT_API_URL
|
||||
assert cfg.bank_id == DEFAULT_BANK_ID
|
||||
@@ -1,53 +0,0 @@
|
||||
"""End-to-end: the MCP endpoint our Zed config points at actually serves the tools.
|
||||
|
||||
This is the real-LLM bucket (``requires_real_llm``) and is skipped unless a
|
||||
Hindsight server is reachable. It builds the same MCP URL the integration writes
|
||||
into Zed's settings and confirms a JSON-RPC ``tools/list`` returns the Hindsight
|
||||
memory tools — i.e. that the config we generate points at a working server.
|
||||
|
||||
HINDSIGHT_API_URL=http://localhost:8888 \
|
||||
uv run pytest tests -v -m requires_real_llm
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_zed.zed_settings import mcp_endpoint_url
|
||||
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
HINDSIGHT_API_TOKEN = os.getenv("HINDSIGHT_API_TOKEN")
|
||||
|
||||
|
||||
def _reachable() -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{HINDSIGHT_API_URL}/health", timeout=3) as r:
|
||||
return r.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.requires_real_llm,
|
||||
pytest.mark.skipif(not _reachable(), reason=f"Hindsight not reachable at {HINDSIGHT_API_URL}"),
|
||||
]
|
||||
|
||||
|
||||
def test_mcp_endpoint_lists_memory_tools():
|
||||
url = mcp_endpoint_url(HINDSIGHT_API_URL, "zed-e2e")
|
||||
body = json.dumps({"jsonrpc": "2.0", "method": "tools/list", "id": 1}).encode()
|
||||
req = urllib.request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json, text/event-stream")
|
||||
if HINDSIGHT_API_TOKEN:
|
||||
req.add_header("Authorization", f"Bearer {HINDSIGHT_API_TOKEN}")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
text = r.read().decode("utf-8", "replace")
|
||||
|
||||
# Streamable-HTTP may answer as SSE; tolerate either by scanning the text.
|
||||
assert "recall" in text and "retain" in text, f"tools/list did not surface memory tools: {text[:300]}"
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Tests for the global-rules writer."""
|
||||
|
||||
from hindsight_zed.rules_file import (
|
||||
BEGIN_MARKER,
|
||||
END_MARKER,
|
||||
RULE_TEXT,
|
||||
clear_rule,
|
||||
is_installed,
|
||||
write_rule,
|
||||
)
|
||||
|
||||
|
||||
def test_write_creates_file_with_block(tmp_path):
|
||||
path = tmp_path / "AGENTS.md"
|
||||
write_rule(path)
|
||||
text = path.read_text()
|
||||
assert BEGIN_MARKER in text and END_MARKER in text
|
||||
assert "recall" in text and "retain" in text
|
||||
assert is_installed(path)
|
||||
|
||||
|
||||
def test_write_preserves_user_content_and_leads_with_block(tmp_path):
|
||||
path = tmp_path / "AGENTS.md"
|
||||
path.write_text("# My project rules\n\nAlways use tabs.\n")
|
||||
write_rule(path)
|
||||
text = path.read_text()
|
||||
assert "Always use tabs." in text # preserved
|
||||
assert text.index(BEGIN_MARKER) < text.index("Always use tabs.") # block leads
|
||||
|
||||
|
||||
def test_write_replaces_existing_block(tmp_path):
|
||||
path = tmp_path / "AGENTS.md"
|
||||
write_rule(path)
|
||||
write_rule(path)
|
||||
text = path.read_text()
|
||||
assert text.count(BEGIN_MARKER) == 1 # not duplicated
|
||||
|
||||
|
||||
def test_clear_removes_block_but_keeps_user_content(tmp_path):
|
||||
path = tmp_path / "AGENTS.md"
|
||||
path.write_text("Keep me.\n")
|
||||
write_rule(path)
|
||||
clear_rule(path)
|
||||
text = path.read_text()
|
||||
assert "Keep me." in text
|
||||
assert BEGIN_MARKER not in text
|
||||
|
||||
|
||||
def test_clear_deletes_file_if_only_our_block(tmp_path):
|
||||
path = tmp_path / "AGENTS.md"
|
||||
write_rule(path)
|
||||
clear_rule(path)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_clear_noop_when_absent(tmp_path):
|
||||
path = tmp_path / "AGENTS.md"
|
||||
clear_rule(path) # should not raise
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_rule_text_mentions_all_three_tools():
|
||||
for tool in ("recall", "retain", "reflect"):
|
||||
assert tool in RULE_TEXT
|
||||
@@ -1,102 +0,0 @@
|
||||
"""Tests for the Zed settings.json context_servers writer."""
|
||||
|
||||
import json
|
||||
|
||||
from hindsight_zed.zed_settings import (
|
||||
SERVER_NAME,
|
||||
apply_to_settings,
|
||||
build_context_server,
|
||||
is_installed,
|
||||
mcp_endpoint_url,
|
||||
remove_from_settings,
|
||||
render_snippet,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildContextServer:
|
||||
def test_endpoint_url_embeds_bank(self):
|
||||
assert mcp_endpoint_url("https://api.hindsight.vectorize.io", "proj") == (
|
||||
"https://api.hindsight.vectorize.io/mcp/proj/"
|
||||
)
|
||||
# Trailing slash on the api url is normalized.
|
||||
assert mcp_endpoint_url("http://localhost:8888/", "b") == "http://localhost:8888/mcp/b/"
|
||||
|
||||
def test_cloud_server_has_auth_header(self):
|
||||
server = build_context_server("https://api.hindsight.vectorize.io", "hsk_abc", "proj")
|
||||
assert server["command"] == "npx"
|
||||
assert "mcp-remote" in server["args"]
|
||||
assert "https://api.hindsight.vectorize.io/mcp/proj/" in server["args"]
|
||||
assert "--header" in server["args"]
|
||||
assert "Authorization: Bearer hsk_abc" in server["args"]
|
||||
|
||||
def test_open_server_omits_auth_header(self):
|
||||
server = build_context_server("http://localhost:8888", None, "proj")
|
||||
assert "--header" not in server["args"]
|
||||
|
||||
|
||||
class TestApplyToSettings:
|
||||
def test_creates_file_when_absent(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
server = build_context_server("https://api.hindsight.vectorize.io", "k", "b")
|
||||
result = apply_to_settings(path, server)
|
||||
assert result.action == "created"
|
||||
data = json.loads(path.read_text())
|
||||
assert data["context_servers"][SERVER_NAME] == server
|
||||
|
||||
def test_merges_into_existing_and_preserves_other_keys(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(json.dumps({"theme": "One Dark", "context_servers": {"other": {"command": "x"}}}))
|
||||
server = build_context_server("https://api.hindsight.vectorize.io", "k", "b")
|
||||
result = apply_to_settings(path, server)
|
||||
assert result.action == "merged"
|
||||
data = json.loads(path.read_text())
|
||||
assert data["theme"] == "One Dark" # untouched
|
||||
assert data["context_servers"]["other"] == {"command": "x"} # untouched
|
||||
assert data["context_servers"][SERVER_NAME] == server
|
||||
|
||||
def test_unchanged_when_identical(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
server = build_context_server("https://api.hindsight.vectorize.io", "k", "b")
|
||||
apply_to_settings(path, server)
|
||||
result = apply_to_settings(path, server)
|
||||
assert result.action == "unchanged"
|
||||
|
||||
def test_jsonc_file_returns_manual_and_is_not_rewritten(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
original = '{\n // my comment\n "theme": "One Dark",\n}\n'
|
||||
path.write_text(original)
|
||||
server = build_context_server("https://api.hindsight.vectorize.io", "k", "b")
|
||||
result = apply_to_settings(path, server)
|
||||
assert result.action == "manual"
|
||||
assert result.snippet is not None and SERVER_NAME in result.snippet
|
||||
assert path.read_text() == original # never touched the commented file
|
||||
|
||||
|
||||
class TestRemoveAndStatus:
|
||||
def test_remove_deletes_only_our_entry(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(json.dumps({"context_servers": {"other": {"command": "x"}, SERVER_NAME: {"command": "npx"}}}))
|
||||
result = remove_from_settings(path)
|
||||
assert result.action == "removed"
|
||||
data = json.loads(path.read_text())
|
||||
assert SERVER_NAME not in data["context_servers"]
|
||||
assert "other" in data["context_servers"]
|
||||
|
||||
def test_remove_drops_empty_context_servers_key(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(json.dumps({"theme": "x", "context_servers": {SERVER_NAME: {"command": "npx"}}}))
|
||||
remove_from_settings(path)
|
||||
data = json.loads(path.read_text())
|
||||
assert "context_servers" not in data
|
||||
assert data["theme"] == "x"
|
||||
|
||||
def test_is_installed(self, tmp_path):
|
||||
path = tmp_path / "settings.json"
|
||||
assert is_installed(path) is False
|
||||
apply_to_settings(path, build_context_server("https://api.hindsight.vectorize.io", "k", "b"))
|
||||
assert is_installed(path) is True
|
||||
|
||||
def test_render_snippet_is_valid_json(self, tmp_path):
|
||||
server = build_context_server("https://api.hindsight.vectorize.io", "k", "b")
|
||||
snippet = render_snippet(server)
|
||||
assert json.loads(snippet)["context_servers"][SERVER_NAME] == server
|
||||
Generated
-185
@@ -1,185 +0,0 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hindsight-zed"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pytest", specifier = ">=9.0.2" },
|
||||
{ name = "ruff", specifier = ">=0.8.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.17"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user