jevgraph 0.2.0a1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jevgraph/__init__.py +23 -0
- jevgraph/cli.py +104 -0
- jevgraph/client.py +55 -0
- jevgraph/demo.py +48 -0
- jevgraph/extension/background.js +113 -0
- jevgraph/extension/capture.js +34 -0
- jevgraph/extension/manifest.json +14 -0
- jevgraph/extension/panel.css +1 -0
- jevgraph/extension/panel.html +8 -0
- jevgraph/extension/panel.js +82 -0
- jevgraph/graph.py +385 -0
- jevgraph/judges.py +101 -0
- jevgraph/server.py +173 -0
- jevgraph/store.py +107 -0
- jevgraph/types.py +166 -0
- jevgraph/web/app.css +2 -0
- jevgraph/web/app.js +195 -0
- jevgraph/web/example-collection.json +7 -0
- jevgraph/web/index.html +45 -0
- jevgraph/workspace.py +317 -0
- jevgraph-0.2.0a1.dist-info/METADATA +94 -0
- jevgraph-0.2.0a1.dist-info/RECORD +25 -0
- jevgraph-0.2.0a1.dist-info/WHEEL +4 -0
- jevgraph-0.2.0a1.dist-info/entry_points.txt +2 -0
- jevgraph-0.2.0a1.dist-info/licenses/LICENSE +22 -0
jevgraph/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""JEVGRAPH: build reusable evidence graphs as agents search."""
|
|
2
|
+
|
|
3
|
+
from .client import IngestionClient
|
|
4
|
+
from .graph import Graph
|
|
5
|
+
from .judges import JevJudge, Judge, ProviderError
|
|
6
|
+
from .types import Budget, Edge, Evaluation, Node, Path, Policy, Relation, SearchResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Budget",
|
|
10
|
+
"Edge",
|
|
11
|
+
"Evaluation",
|
|
12
|
+
"Graph",
|
|
13
|
+
"IngestionClient",
|
|
14
|
+
"JevJudge",
|
|
15
|
+
"Judge",
|
|
16
|
+
"Node",
|
|
17
|
+
"Path",
|
|
18
|
+
"Policy",
|
|
19
|
+
"ProviderError",
|
|
20
|
+
"Relation",
|
|
21
|
+
"SearchResult",
|
|
22
|
+
]
|
|
23
|
+
__version__ = "0.2.0a1"
|
jevgraph/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The demo is offline unless --live is supplied explicitly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .demo import DEMO_NODES, DEMO_QUERY, DEMO_RELATIONS, DemoJudge
|
|
11
|
+
from .graph import Graph
|
|
12
|
+
from .judges import JevJudge
|
|
13
|
+
from .types import Budget
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
parser = argparse.ArgumentParser(description="JEVGRAPH: graphs that grow as agents search")
|
|
18
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
19
|
+
demo = commands.add_parser("demo", help="Run the synthetic dependency-chain example")
|
|
20
|
+
demo.add_argument("--live", action="store_true", help="Send synthetic records to TypeSafe JEV")
|
|
21
|
+
demo.add_argument("--db", default=":memory:", help="SQLite path for persistent graph memory")
|
|
22
|
+
demo.add_argument("--json", action="store_true", help="Print complete trace and graph JSON")
|
|
23
|
+
demo.add_argument("--out", type=Path, help="Save complete trace and graph JSON")
|
|
24
|
+
server = commands.add_parser("serve", help="Open the local workspace search app")
|
|
25
|
+
server.add_argument("--db", default="workspace.sqlite")
|
|
26
|
+
server.add_argument("--token-file", default=".jevgraph/access.json")
|
|
27
|
+
server.add_argument("--port", type=int, default=8765)
|
|
28
|
+
server.add_argument("--live", action="store_true", help="Enable optional JEV graph exploration")
|
|
29
|
+
ingest = commands.add_parser("ingest", help="Send a JSON dump to your workspace")
|
|
30
|
+
ingest.add_argument("file", type=Path)
|
|
31
|
+
ingest.add_argument("--server", default="http://127.0.0.1:8765")
|
|
32
|
+
ingest.add_argument("--token-file", default=".jevgraph/access.json")
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
try:
|
|
35
|
+
if args.command == "ingest":
|
|
36
|
+
from .client import IngestionClient
|
|
37
|
+
|
|
38
|
+
access_token = os.environ.get("JEVGRAPH_ACCESS_TOKEN", "")
|
|
39
|
+
if not access_token:
|
|
40
|
+
configured = json.loads(Path(args.token_file).read_text())
|
|
41
|
+
if len(configured) != 1:
|
|
42
|
+
parser.error("Set JEVGRAPH_ACCESS_TOKEN to select your account")
|
|
43
|
+
access_token = next(iter(configured))
|
|
44
|
+
payload = json.loads(args.file.read_text())
|
|
45
|
+
records = payload if isinstance(payload, list) else payload.get("records")
|
|
46
|
+
print(json.dumps(IngestionClient(args.server, access_token).ingest(records), indent=2))
|
|
47
|
+
return
|
|
48
|
+
if args.command == "serve":
|
|
49
|
+
try:
|
|
50
|
+
from .server import serve
|
|
51
|
+
except ImportError:
|
|
52
|
+
parser.error("Install server dependencies with: pip install '.[server]'")
|
|
53
|
+
serve(args.db, args.token_file, args.port, args.live)
|
|
54
|
+
return
|
|
55
|
+
judge = JevJudge() if args.live else DemoJudge()
|
|
56
|
+
with Graph(args.db, name="dependency-demo", relations=DEMO_RELATIONS, judge=judge) as graph:
|
|
57
|
+
for node in DEMO_NODES:
|
|
58
|
+
graph.add(node)
|
|
59
|
+
result = graph.search(
|
|
60
|
+
DEMO_QUERY,
|
|
61
|
+
start=["edgerouter"],
|
|
62
|
+
budget=Budget(max_calls=32, max_questions=100, candidates_per_node=3),
|
|
63
|
+
)
|
|
64
|
+
payload = {
|
|
65
|
+
"mode": "live-jev" if args.live else "scripted-offline-demo",
|
|
66
|
+
"result": result.to_dict(),
|
|
67
|
+
"graph": graph.export(),
|
|
68
|
+
}
|
|
69
|
+
rendered = json.dumps(payload, indent=2, allow_nan=False)
|
|
70
|
+
if args.out:
|
|
71
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
args.out.write_text(rendered + "\n")
|
|
73
|
+
if args.json:
|
|
74
|
+
print(rendered)
|
|
75
|
+
else:
|
|
76
|
+
print(f"JEVGRAPH | {payload['mode']}")
|
|
77
|
+
print(
|
|
78
|
+
"Fixture answers exercise the code; they do not measure JEV quality."
|
|
79
|
+
if not args.live
|
|
80
|
+
else "Synthetic source records evaluated by the live JEV API."
|
|
81
|
+
)
|
|
82
|
+
print(f"Stop: {result.stop_reason}")
|
|
83
|
+
best = max(result.paths, key=lambda p: (p.goal_probability, p.priority), default=None)
|
|
84
|
+
if best:
|
|
85
|
+
print("Path: " + " -> ".join(best.nodes))
|
|
86
|
+
print(f"Judge calls: {result.calls} | questions: {result.questions}")
|
|
87
|
+
print(
|
|
88
|
+
"Reused judgments: "
|
|
89
|
+
+ str(
|
|
90
|
+
sum(t.get("count", 0) for t in result.trace if t["event"] == "judgment_cache")
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
print(
|
|
94
|
+
"Supported relationships: "
|
|
95
|
+
+ str(sum(e["status"] == "supported" for e in payload["graph"]["judgments"]))
|
|
96
|
+
)
|
|
97
|
+
if result.stop_reason == "provider_error":
|
|
98
|
+
raise SystemExit(2)
|
|
99
|
+
except (ValueError, OSError) as exc:
|
|
100
|
+
parser.error(str(exc))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
jevgraph/client.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Small ingestion client for browser agents and existing export pipelines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
from .types import canonical
|
|
11
|
+
from .workspace import validate_record
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
15
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IngestionClient:
|
|
20
|
+
def __init__(self, server: str, token: str):
|
|
21
|
+
parts = urlsplit(server)
|
|
22
|
+
if parts.username or parts.password or parts.query or parts.fragment:
|
|
23
|
+
raise ValueError("Use the workspace origin without credentials or query parameters")
|
|
24
|
+
if parts.path not in {"", "/"} or not parts.hostname:
|
|
25
|
+
raise ValueError("Use the workspace origin without a path")
|
|
26
|
+
if parts.scheme != "https" and not (
|
|
27
|
+
parts.scheme == "http" and parts.hostname in {"localhost", "127.0.0.1"}
|
|
28
|
+
):
|
|
29
|
+
raise ValueError("Use HTTPS or a loopback HTTP workspace")
|
|
30
|
+
if not token:
|
|
31
|
+
raise ValueError("A workspace access token is required")
|
|
32
|
+
self.server = server.rstrip("/")
|
|
33
|
+
self._token = token
|
|
34
|
+
|
|
35
|
+
def ingest(self, records: list[dict]) -> dict:
|
|
36
|
+
if not isinstance(records, list) or not 1 <= len(records) <= 50:
|
|
37
|
+
raise ValueError("Send 1–50 records per batch")
|
|
38
|
+
payload = canonical({"records": [validate_record(record) for record in records]}).encode()
|
|
39
|
+
if len(payload) > 2_000_000:
|
|
40
|
+
raise ValueError("Batch exceeds 2 MB")
|
|
41
|
+
request = urllib.request.Request(
|
|
42
|
+
self.server + "/api/ingest",
|
|
43
|
+
data=payload,
|
|
44
|
+
method="POST",
|
|
45
|
+
headers={"Authorization": "Bearer " + self._token, "Content-Type": "application/json"},
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.build_opener(_NoRedirect()).open(request, timeout=30) as response:
|
|
49
|
+
return json.loads(response.read(256000))
|
|
50
|
+
except urllib.error.HTTPError as error:
|
|
51
|
+
raise ValueError(f"Ingestion returned HTTP {error.code}; no retry attempted") from None
|
|
52
|
+
except (OSError, ValueError):
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"Unable to read ingestion response; check server and retry the batch"
|
|
55
|
+
) from None
|
jevgraph/demo.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Synthetic fixture for exercising mechanics, explicitly not model evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .types import Evaluation, Node, Relation
|
|
8
|
+
|
|
9
|
+
DEMO_RELATIONS = [Relation(
|
|
10
|
+
"depends_on", "The source service requires the target service to perform its function.",
|
|
11
|
+
"service", "service",
|
|
12
|
+
)]
|
|
13
|
+
DEMO_NODES = [
|
|
14
|
+
Node("edgerouter", "EdgeRouter forwards checkout requests to Checkout. "
|
|
15
|
+
"EdgeRouter depends on Checkout.", "service", "fixture://architecture/edgerouter"),
|
|
16
|
+
Node("checkout", "Checkout submits card charges to Payments. Checkout depends on Payments.",
|
|
17
|
+
"service", "fixture://architecture/checkout"),
|
|
18
|
+
Node("payments", "Payments authenticates through Identity. Payments depends on Identity.",
|
|
19
|
+
"service", "fixture://architecture/payments"),
|
|
20
|
+
Node("identity", "Identity issues access tokens for signed-in sessions.",
|
|
21
|
+
"service", "fixture://architecture/identity"),
|
|
22
|
+
Node("legacy", "LegacyAuth and Checkout were discussed in a proposal. "
|
|
23
|
+
"Checkout does not depend on LegacyAuth.", "service", "fixture://architecture/legacy"),
|
|
24
|
+
]
|
|
25
|
+
DEMO_QUERY = "Starting at EdgeRouter, find a chain of service dependencies reaching Identity."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DemoJudge:
|
|
29
|
+
"""Hard-coded answers for these five records only. No semantic inference."""
|
|
30
|
+
|
|
31
|
+
cache_key = "scripted-demo-v1"
|
|
32
|
+
pairs = {("edgerouter", "checkout"), ("checkout", "payments"), ("payments", "identity")}
|
|
33
|
+
|
|
34
|
+
def evaluate(self, state: dict[str, Any], questions: dict[str, str]) -> Evaluation:
|
|
35
|
+
if "source" in state:
|
|
36
|
+
pair = (state["source"]["id"], state["target"]["id"])
|
|
37
|
+
values = {key: (0.98 if pair in self.pairs else 0.02)
|
|
38
|
+
if key.startswith("support_") else
|
|
39
|
+
(0.98 if pair == ("checkout", "legacy") else 0.02)
|
|
40
|
+
for key in questions}
|
|
41
|
+
elif "goal" in questions:
|
|
42
|
+
ids = [node["id"] for node in state["path"]["nodes"]]
|
|
43
|
+
values = {"goal": 0.98 if ids == ["edgerouter", "checkout", "payments", "identity"]
|
|
44
|
+
else 0.02}
|
|
45
|
+
else:
|
|
46
|
+
values = {key: 0.98 for key in questions}
|
|
47
|
+
return Evaluation(values, "scripted-demo-v1")
|
|
48
|
+
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { capturePage } from './capture.js';
|
|
2
|
+
|
|
3
|
+
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
|
|
4
|
+
chrome.storage.local.setAccessLevel({ accessLevel: 'TRUSTED_CONTEXTS' });
|
|
5
|
+
let processing = false;
|
|
6
|
+
let edits = Promise.resolve();
|
|
7
|
+
const hash = async value => [...new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
8
|
+
const fingerprint = settings => hash(settings.baseUrl + ':' + settings.token);
|
|
9
|
+
|
|
10
|
+
function baseUrl(value) {
|
|
11
|
+
const url = new URL(value);
|
|
12
|
+
if (url.username || url.password || url.search || url.hash || url.pathname !== '/') throw new Error('Use the workspace origin, without a path or credentials.');
|
|
13
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))) throw new Error('Use HTTPS, or a local workspace on localhost.');
|
|
14
|
+
return url.origin;
|
|
15
|
+
}
|
|
16
|
+
async function request(settings, path, options = {}) {
|
|
17
|
+
const response = await fetch(settings.baseUrl + '/api/' + path, {
|
|
18
|
+
...options, redirect: 'error', signal: AbortSignal.timeout(20000),
|
|
19
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${settings.token}` },
|
|
20
|
+
});
|
|
21
|
+
const payload = await response.json().catch(() => ({}));
|
|
22
|
+
if (!response.ok) throw new Error(payload.detail || `Workspace returned ${response.status}`);
|
|
23
|
+
return payload;
|
|
24
|
+
}
|
|
25
|
+
function editJobs(change) {
|
|
26
|
+
const run = edits.then(async () => {
|
|
27
|
+
const { jobs = [] } = await chrome.storage.local.get('jobs');
|
|
28
|
+
const next = await change(jobs);
|
|
29
|
+
await chrome.storage.local.set({ jobs: next });
|
|
30
|
+
return next;
|
|
31
|
+
});
|
|
32
|
+
edits = run.catch(() => {});
|
|
33
|
+
return run;
|
|
34
|
+
}
|
|
35
|
+
async function settingsOrFail() {
|
|
36
|
+
const { settings } = await chrome.storage.local.get('settings');
|
|
37
|
+
if (!settings?.token) throw new Error('Connect your workspace in Settings first.');
|
|
38
|
+
return settings;
|
|
39
|
+
}
|
|
40
|
+
async function enqueue(tabIds) {
|
|
41
|
+
const settings = await settingsOrFail();
|
|
42
|
+
if (!Array.isArray(tabIds) || !tabIds.length || tabIds.length > 20) throw new Error('Select between 1 and 20 tabs.');
|
|
43
|
+
const scope = await fingerprint(settings);
|
|
44
|
+
const tabs = await Promise.all([...new Set(tabIds)].map(id => chrome.tabs.get(id)));
|
|
45
|
+
if (tabs.some(tab => tab.incognito || !/^https?:\/\//.test(tab.url || ''))) throw new Error('Select ordinary HTTP(S) tabs outside incognito.');
|
|
46
|
+
await editJobs(jobs => {
|
|
47
|
+
const active = jobs.filter(job => ['pending', 'capturing'].includes(job.status));
|
|
48
|
+
if (active.length + tabs.length > 20) throw new Error('The queue supports 20 active captures.');
|
|
49
|
+
const next = tabs.map(tab => ({ id: crypto.randomUUID(), tabId: tab.id, tabUrl: tab.url,
|
|
50
|
+
title: tab.title || 'Page', status: 'pending', scope, createdAt: new Date().toISOString() }));
|
|
51
|
+
return [...jobs.filter(job => !active.includes(job)).slice(-(20 - active.length - next.length) || jobs.length), ...active, ...next].slice(-20);
|
|
52
|
+
});
|
|
53
|
+
await chrome.alarms.create('capture-queue', { periodInMinutes: 1 });
|
|
54
|
+
void drain();
|
|
55
|
+
return { queued: tabs.length };
|
|
56
|
+
}
|
|
57
|
+
async function drain() {
|
|
58
|
+
if (processing) return;
|
|
59
|
+
processing = true;
|
|
60
|
+
try {
|
|
61
|
+
const { jobs = [] } = await chrome.storage.local.get('jobs');
|
|
62
|
+
const job = jobs.find(item => ['pending', 'capturing'].includes(item.status));
|
|
63
|
+
if (!job) { await chrome.alarms.clear('capture-queue'); return; }
|
|
64
|
+
try {
|
|
65
|
+
const settings = await settingsOrFail();
|
|
66
|
+
if (await fingerprint(settings) !== job.scope) throw new Error('Workspace connection changed. Capture this page again for the new connection.');
|
|
67
|
+
await editJobs(list => list.map(item => item.id === job.id ? { ...item, status: 'capturing', error: '' } : item));
|
|
68
|
+
let record = job.record;
|
|
69
|
+
if (!record) {
|
|
70
|
+
const tab = await chrome.tabs.get(job.tabId);
|
|
71
|
+
if (tab.incognito || tab.url !== job.tabUrl) throw new Error('The tab navigated or closed. Select it again to capture its current page.');
|
|
72
|
+
const [{ result }] = await chrome.scripting.executeScript({ target: { tabId: job.tabId }, func: capturePage });
|
|
73
|
+
if (!result || result._originalUrl !== job.tabUrl) throw new Error('Page changed during capture. Select it again.');
|
|
74
|
+
record = { ...result, external_id: 'browser:' + await hash(result._identity) };
|
|
75
|
+
delete record._originalUrl; delete record._identity;
|
|
76
|
+
await editJobs(list => list.map(item => item.id === job.id ? { ...item, record } : item));
|
|
77
|
+
}
|
|
78
|
+
const receipt = await request(settings, 'ingest', { method: 'POST', body: JSON.stringify({ records: [record] }) });
|
|
79
|
+
await editJobs(list => list.map(item => item.id === job.id ? { ...item, status: 'saved', receipt, title: record.title } : item));
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await editJobs(list => list.map(item => item.id === job.id ? { ...item, status: 'failed', error: error.message } : item));
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
processing = false;
|
|
85
|
+
const { jobs = [] } = await chrome.storage.local.get('jobs');
|
|
86
|
+
if (jobs.some(job => job.status === 'pending')) void drain();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === 'capture-queue') void drain(); });
|
|
90
|
+
chrome.runtime.onStartup.addListener(() => { void drain(); });
|
|
91
|
+
chrome.runtime.onMessage.addListener((message, sender, reply) => {
|
|
92
|
+
// Only our extension pages may invoke authenticated ingestion or search.
|
|
93
|
+
if (sender.id !== chrome.runtime.id || !sender.url?.startsWith(`chrome-extension://${chrome.runtime.id}/`)) return false;
|
|
94
|
+
(async () => {
|
|
95
|
+
if (message.type === 'connect') {
|
|
96
|
+
const settings = { baseUrl: baseUrl(message.baseUrl), token: String(message.token).trim() };
|
|
97
|
+
const me = await request(settings, 'me');
|
|
98
|
+
await chrome.storage.local.set({ settings: { ...settings, me } });
|
|
99
|
+
return me;
|
|
100
|
+
}
|
|
101
|
+
if (message.type === 'capture') return enqueue(message.tabIds);
|
|
102
|
+
if (message.type === 'search') return request(await settingsOrFail(), 'search', { method: 'POST', body: JSON.stringify({ query: String(message.query), explore: false }) });
|
|
103
|
+
if (message.type === 'retry') {
|
|
104
|
+
await editJobs(jobs => jobs.map(job => job.status === 'failed' ? { ...job, status: 'pending', error: '' } : job));
|
|
105
|
+
await chrome.alarms.create('capture-queue', { periodInMinutes: 1 }); void drain(); return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
if (message.type === 'clear') {
|
|
108
|
+
await editJobs(jobs => jobs.filter(job => ['pending', 'capturing'].includes(job.status))); return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
throw new Error('Unknown action');
|
|
111
|
+
})().then(data => reply({ ok: true, data }), error => reply({ ok: false, error: error.message }));
|
|
112
|
+
return true;
|
|
113
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Self-contained: Chrome serializes this function into an isolated content world.
|
|
2
|
+
export function capturePage() {
|
|
3
|
+
if (!['http:', 'https:'].includes(location.protocol)) throw new Error('Only web pages can be captured.');
|
|
4
|
+
const originalUrl = location.href;
|
|
5
|
+
const url = new URL(originalUrl);
|
|
6
|
+
url.username = ''; url.password = '';
|
|
7
|
+
const allowed = new Set(['id', 'page', 'record', 'issue', 'view']);
|
|
8
|
+
for (const key of [...url.searchParams.keys()]) if (!allowed.has(key.toLowerCase())) url.searchParams.delete(key);
|
|
9
|
+
url.searchParams.sort();
|
|
10
|
+
url.hash = location.hash.startsWith('#/') ? location.hash.split('?')[0] : '';
|
|
11
|
+
const root = document.querySelector('main, article, [role="main"]') || document.body;
|
|
12
|
+
if (!root) throw new Error('This page has no readable content.');
|
|
13
|
+
const excluded = 'script,style,noscript,input,textarea,select,button,nav,footer,[contenteditable],[hidden],[aria-hidden="true"]';
|
|
14
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
15
|
+
let text = '', count = 0, truncated = false;
|
|
16
|
+
while (walker.nextNode()) {
|
|
17
|
+
if (++count > 60000) { truncated = true; break; }
|
|
18
|
+
const current = walker.currentNode;
|
|
19
|
+
const parent = current.parentElement;
|
|
20
|
+
if (!parent || parent.closest(excluded)) continue;
|
|
21
|
+
const style = getComputedStyle(parent);
|
|
22
|
+
if (!parent.getClientRects().length || style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') continue;
|
|
23
|
+
const value = current.textContent.replace(/\s+/g, ' ').trim();
|
|
24
|
+
if (!value) continue;
|
|
25
|
+
if (text.length + value.length + 1 > 100000) {
|
|
26
|
+
text += value.slice(0, Math.max(0, 100000 - text.length)); truncated = true; break;
|
|
27
|
+
}
|
|
28
|
+
text += value + '\n';
|
|
29
|
+
}
|
|
30
|
+
if (!text.trim()) throw new Error('No readable page text. Try a source export for this page.');
|
|
31
|
+
return { title: (document.title || url.hostname).slice(0, 500), url: url.href, text: text.trim(),
|
|
32
|
+
source_type: 'browser', visibility: 'private', truncated, _originalUrl: originalUrl,
|
|
33
|
+
_identity: url.href };
|
|
34
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "JEVGRAPH — Workspace capture",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Capture selected pages, collect tabs, and search your private JEVGRAPH workspace.",
|
|
6
|
+
"minimum_chrome_version": "116",
|
|
7
|
+
"permissions": ["activeTab", "scripting", "storage", "sidePanel", "tabs", "alarms"],
|
|
8
|
+
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
|
|
9
|
+
"optional_host_permissions": ["https://*/*", "http://*/*"],
|
|
10
|
+
"background": {"service_worker": "background.js", "type": "module"},
|
|
11
|
+
"action": {"default_title": "Open JEVGRAPH"},
|
|
12
|
+
"side_panel": {"default_path": "panel.html"},
|
|
13
|
+
"content_security_policy": {"extension_pages": "script-src 'self'; object-src 'self'"}
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#304a36;background:#f9faf6;font-synthesis:none}*{box-sizing:border-box}body{margin:0;padding:21px;min-width:280px}button,input{font:inherit}button{cursor:pointer}[hidden]{display:none!important}header{display:flex;align-items:center;justify-content:space-between;padding:3px 0 22px}.brand{display:flex;gap:9px;align-items:center;font-weight:750;font-size:16px;letter-spacing:-.5px}.brand>span{background:#254b3b;border-radius:9px;color:#d4e8a7;width:29px;height:29px;display:grid;place-items:center;font-size:26px}a{color:#56764f;text-decoration:none}header>a{font-size:22px}.workspace{display:flex;align-items:center;gap:8px;font-size:10px;border:1px solid #e0e6d8;border-radius:8px;padding:13px;background:#f0f4e9}.dot{height:5px;width:5px;border-radius:50%;background:#8da774}.badge{margin-left:auto;font-size:9px;color:#849575}nav{display:flex;border-bottom:1px solid #e1e6d8;margin:25px 0}nav button{border:0;background:none;padding:12px 14px;color:#87917e;font-size:12px}nav button.active{color:#315635;border-bottom:2px solid #496c43}.eyebrow{font-size:8px;letter-spacing:1.4px;color:#8a9c78}h1{font-size:32px;font-weight:460;line-height:1.2;letter-spacing:-1.2px;margin:16px 0}p{font-size:12px;line-height:1.8;color:#85917a}button.primary,button.secondary{border:1px solid transparent;padding:13px 16px;border-radius:8px;width:100%;font-size:12px;font-weight:550;margin-top:10px}.primary{background:#29573f;color:#fff}.primary:hover{background:#376b4b}.secondary{background:#eaf0e2;color:#45663d;border-color:#dce6d2!important}.section-head{display:flex;justify-content:space-between;align-items:center;margin:29px 0 15px}h2{font-size:12px;font-weight:550;margin:0}.text-button{border:0;padding:0;background:none;font-size:10px;color:#6f8c5b}.tab-list{max-height:225px;overflow:auto;border:1px solid #e0e6d9;border-radius:8px}.tab-item{display:flex!important;align-items:center;gap:9px;padding:11px;margin:0!important;border-bottom:1px solid #e5eadf}.tab-item:last-child{border:0}.tab-item input{width:13px;margin:0;accent-color:#456d3c;flex-shrink:0}.tab-item div{min-width:0}.tab-item strong{font-size:11px;font-weight:500;display:block;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;color:#57694b}.tab-item small{font-size:9px;color:#a0aa96;display:block;margin-top:4px}.fine{font-size:10px;line-height:1.8}label{display:block;margin:17px 0 8px;font-size:11px;color:#6d7e60}input{width:100%;border:1px solid #dce4d1;border-radius:7px;padding:12px;color:#3d5633;background:#fff;outline-color:#91af7c;font-size:12px}.job{padding:12px 0;border-bottom:1px solid #e4e9dd}.job strong{font-size:11px;font-weight:500;display:block}.job small{font-size:10px;display:block;margin-top:6px;color:#90a07f}.job.failed small{color:#ac6755}.job.saved small{color:#608953}.queue-actions{display:flex;justify-content:space-between;gap:10px;margin:17px 0}.empty{font-size:11px;color:#98a68b;border:1px dashed #dbe3d1;border-radius:8px;padding:18px;line-height:1.8}.result{padding:16px 0;border-bottom:1px solid #e2e7dc}.result a{font-size:13px;font-weight:550}.result p{font-size:11px;margin-bottom:6px;overflow-wrap:anywhere}.result small{font-size:9px;color:#96a48a}#status{position:sticky;bottom:10px;background:#2a5039;color:white;font-size:11px;line-height:1.6;border-radius:8px;padding:12px;margin-top:20px;overflow-wrap:anywhere}footer{font-size:9px;letter-spacing:.5px;color:#a4ae9a;border-top:1px solid #e3e9da;margin-top:30px;padding-top:18px}button:focus-visible,a:focus-visible{outline:2px solid #85a473;outline-offset:3px}button:disabled{opacity:.45;cursor:not-allowed}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>JEVGRAPH capture</title><link rel="stylesheet" href="panel.css"><script type="module" src="panel.js"></script></head><body>
|
|
2
|
+
<header><div class="brand"><span>✳</span> JEVGRAPH</div><a id="open-workspace" href="http://127.0.0.1:8765" target="_blank" rel="noopener noreferrer" aria-label="Open workspace">↗</a></header>
|
|
3
|
+
<div class="workspace"><span class="dot"></span><span id="workspace-label">Connect your workspace</span><span class="badge">Private</span></div>
|
|
4
|
+
<nav><button class="active" data-tab="capture">Capture</button><button data-tab="search">Search</button><button data-tab="settings">Settings</button></nav>
|
|
5
|
+
<section id="capture-tab"><span class="eyebrow">KEEP WHAT MATTERS</span><h1>The page now.<br>The context later.</h1><p>Save readable page text to your private workspace. Search it whenever you need it.</p><button id="capture-current" class="primary">+ Capture this page</button><div class="section-head"><h2>Collect a set of tabs</h2><button id="refresh-tabs" class="text-button">Refresh</button></div><div id="tab-list" class="tab-list"></div><button id="capture-selected" class="secondary">Collect selected tabs</button><p class="fine">You choose the pages. Chrome asks for access to the selected sites. Captures include only loaded page text.</p><div class="section-head"><h2>Capture queue</h2><button id="retry" class="text-button">Retry failed</button></div><div id="jobs"></div><div class="queue-actions"><button id="export" class="text-button">Export captured JSON ↓</button><button id="clear" class="text-button">Clear finished</button></div></section>
|
|
6
|
+
<section id="search-tab" hidden><span class="eyebrow">YOUR WORK, CONNECTED</span><h1>Find it here.</h1><form id="search-form"><label for="query">Search your workspace</label><input id="query" required placeholder="A project, decision, or useful page…"><button class="primary" type="submit">Search →</button></form><div id="results"></div></section>
|
|
7
|
+
<section id="settings-tab" hidden><span class="eyebrow">YOUR CONNECTION</span><h1>A home for<br>your knowledge.</h1><form id="settings-form"><label for="base-url">Workspace URL</label><input id="base-url" type="url" required value="http://127.0.0.1:8765"><label for="token">Access token</label><input id="token" type="password" required autocomplete="off" placeholder="From your workspace access file"><button class="primary" type="submit">Connect workspace →</button></form><p class="fine">The workspace token stays in extension storage. Page captures exclude form fields and editable areas. Website cookies and passwords are not collected.</p></section>
|
|
8
|
+
<div id="status" role="status" hidden></div><footer>JEVGRAPH · Browser companion</footer></body></html>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const $ = id => document.getElementById(id);
|
|
2
|
+
let listedTabs = [];
|
|
3
|
+
let currentJobs = [];
|
|
4
|
+
function el(tag, className, text) { const n = document.createElement(tag); if (className) n.className = className; if (text !== undefined) n.textContent = text; return n; }
|
|
5
|
+
function status(message) { $('status').textContent = message; $('status').hidden = false; }
|
|
6
|
+
async function send(message) { const result = await chrome.runtime.sendMessage(message); if (!result?.ok) throw new Error(result?.error || 'Extension did not respond.'); return result.data; }
|
|
7
|
+
function tab(name) { for (const item of document.querySelectorAll('section')) item.hidden = item.id !== name + '-tab'; for (const button of document.querySelectorAll('[data-tab]')) button.classList.toggle('active', button.dataset.tab === name); }
|
|
8
|
+
async function refreshTabs() {
|
|
9
|
+
listedTabs = (await chrome.tabs.query({ currentWindow: true })).filter(item => !item.incognito && /^https?:\/\//.test(item.url || ''));
|
|
10
|
+
$('tab-list').replaceChildren(...listedTabs.map(item => {
|
|
11
|
+
const row = el('label', 'tab-item'); const check = el('input'); check.type = 'checkbox'; check.value = item.id;
|
|
12
|
+
const text = el('div'); text.append(el('strong', '', item.title || 'Page'), el('small', '', new URL(item.url).hostname)); row.append(check, text); return row;
|
|
13
|
+
}));
|
|
14
|
+
if (!listedTabs.length) $('tab-list').append(el('div', 'empty', 'Open a web page to start collecting.'));
|
|
15
|
+
}
|
|
16
|
+
async function renderJobs() {
|
|
17
|
+
const { jobs = [] } = await chrome.storage.local.get('jobs'); currentJobs = jobs;
|
|
18
|
+
$('jobs').replaceChildren(...jobs.slice().reverse().map(job => {
|
|
19
|
+
const row = el('div', 'job ' + job.status); row.append(el('strong', '', job.title), el('small', '', job.status === 'failed' ? job.error : job.status === 'saved' ? 'Saved to your private workspace' + (job.record?.truncated ? ' · text truncated' : '') : job.status === 'capturing' ? 'Capturing and uploading…' : 'Queued')); return row;
|
|
20
|
+
}));
|
|
21
|
+
if (!jobs.length) $('jobs').append(el('div', 'empty', 'Your next useful page belongs here.'));
|
|
22
|
+
$('retry').disabled = !jobs.some(job => job.status === 'failed');
|
|
23
|
+
$('export').disabled = !jobs.some(job => job.record);
|
|
24
|
+
if (jobs.length && !jobs.some(job => ['pending', 'capturing'].includes(job.status))) {
|
|
25
|
+
const saved = jobs.filter(job => job.status === 'saved').length;
|
|
26
|
+
const failed = jobs.filter(job => job.status === 'failed').length;
|
|
27
|
+
status(failed ? `${saved} saved · ${failed} need attention.` : `${saved} captured pages are ready to search.`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function loadSettings() {
|
|
31
|
+
const { settings } = await chrome.storage.local.get('settings');
|
|
32
|
+
if (settings) {
|
|
33
|
+
$('base-url').value = settings.baseUrl; $('token').value = settings.token;
|
|
34
|
+
$('workspace-label').textContent = settings.me.workspace; $('open-workspace').href = settings.baseUrl;
|
|
35
|
+
} else tab('settings');
|
|
36
|
+
}
|
|
37
|
+
for (const button of document.querySelectorAll('[data-tab]')) button.onclick = () => tab(button.dataset.tab);
|
|
38
|
+
$('refresh-tabs').onclick = () => refreshTabs().catch(error => status(error.message));
|
|
39
|
+
$('settings-form').onsubmit = async event => {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
try {
|
|
42
|
+
const target = new URL($('base-url').value);
|
|
43
|
+
const origin = target.protocol + '//' + target.hostname;
|
|
44
|
+
const granted = await chrome.permissions.request({ origins: [origin + '/*'] });
|
|
45
|
+
if (!granted) throw new Error('Workspace access was not granted.');
|
|
46
|
+
await send({ type: 'connect', baseUrl: $('base-url').value, token: $('token').value });
|
|
47
|
+
await loadSettings(); tab('capture'); status('Workspace connected.');
|
|
48
|
+
} catch (error) { status(error.message); }
|
|
49
|
+
};
|
|
50
|
+
$('capture-current').onclick = async () => {
|
|
51
|
+
try { const [current] = await chrome.tabs.query({ active: true, currentWindow: true }); await send({ type: 'capture', tabIds: [current.id] }); status('Page queued. If access is denied, click the toolbar icon on this page and try again.'); }
|
|
52
|
+
catch (error) { status(error.message); }
|
|
53
|
+
};
|
|
54
|
+
$('capture-selected').onclick = async () => {
|
|
55
|
+
try {
|
|
56
|
+
const ids = [...$('tab-list').querySelectorAll('input:checked')].map(input => Number(input.value));
|
|
57
|
+
if (!ids.length || ids.length > 20) throw new Error('Select between 1 and 20 tabs.');
|
|
58
|
+
const origins = [...new Set(listedTabs.filter(item => ids.includes(item.id)).map(item => { const url = new URL(item.url); return url.protocol + '//' + url.hostname + '/*'; }))];
|
|
59
|
+
if (!await chrome.permissions.request({ origins })) throw new Error('Access to selected sites was not granted.');
|
|
60
|
+
await send({ type: 'capture', tabIds: ids }); status(`${ids.length} selected pages queued.`);
|
|
61
|
+
} catch (error) { status(error.message); }
|
|
62
|
+
};
|
|
63
|
+
$('retry').onclick = () => send({ type: 'retry' }).then(() => status('Retrying failed captures.')).catch(error => status(error.message));
|
|
64
|
+
$('clear').onclick = () => send({ type: 'clear' }).catch(error => status(error.message));
|
|
65
|
+
$('export').onclick = () => {
|
|
66
|
+
const records = currentJobs.filter(job => job.record).map(job => job.record);
|
|
67
|
+
const link = el('a'); const url = URL.createObjectURL(new Blob([JSON.stringify({ records }, null, 2)], { type: 'application/json' }));
|
|
68
|
+
link.href = url; link.download = 'jevgraph-captures.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 30000);
|
|
69
|
+
};
|
|
70
|
+
$('search-form').onsubmit = async event => {
|
|
71
|
+
event.preventDefault(); $('results').replaceChildren(el('div', 'empty', 'Searching…'));
|
|
72
|
+
try {
|
|
73
|
+
const result = await send({ type: 'search', query: $('query').value });
|
|
74
|
+
$('results').replaceChildren(...result.results.map(doc => {
|
|
75
|
+
const card = el('article', 'result'); const link = el('a', '', doc.title); link.href = doc.url; link.target = '_blank'; link.rel = 'noopener noreferrer';
|
|
76
|
+
card.append(link, el('p', '', doc.excerpt), el('small', '', new URL(doc.url).hostname)); return card;
|
|
77
|
+
}));
|
|
78
|
+
if (!result.results.length) $('results').append(el('div', 'empty', 'No matching pages yet. Try another phrase or capture more pages.'));
|
|
79
|
+
} catch (error) { $('results').replaceChildren(el('div', 'empty', error.message)); }
|
|
80
|
+
};
|
|
81
|
+
chrome.storage.onChanged.addListener(changes => { if (changes.jobs) void renderJobs(); });
|
|
82
|
+
await loadSettings(); await refreshTabs(); await renderJobs();
|