v0.2.17 : accès internet (Crawl4AI) + 3 modes de mémoire

- accès web de l'IA via un serveur Crawl4AI configurable (outils
  web_search / web_open / web_read / web_grep), réglage CRAWL4AI_URL +
  toggle, actifs seulement si le mode agent et le serveur sont joignables.
  CLI `jean internet [on|off|status|url <url>]`.
- mémoire réglable en 3 modes indépendants du mode agent : off / sur
  demande / auto (proactive). CLI `jean memory [off|ondemand|always|status]`.
- fix : prompt et outils gouvernés par la même condition (plus de
  web_search tapé en bash quand le crawl est injoignable) ; consigne
  année de recherche durcie ; icône 🌐 des outils web dans l'UI.
This commit is contained in:
nathaninline committed 2026-07-08 10:40:41 +02:00
1 parent fbfb3effd7
commit c398cb85e6
11 files changed
+1179 -55

No files matched your search

+3
View File
@@ -93,6 +93,9 @@ func cmdChat(args []string) error {
case "edit":
icon = "✏️"
verb = "édition"
case "web_search", "web_open", "web_read", "web_grep":
icon = "🌐"
verb = "web"
}
if inReason {
fmt.Print("\n")
+841
View File
@@ -0,0 +1,841 @@
package main
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
)
// Accès internet de l'IA — port Go de l'extension pi ~/.pi/agent/extensions/web.ts.
//
// jean parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL
// est configurée dans config.env (CRAWL4AI_URL). Quand le mode agent ET l'accès
// internet sont actifs ET que le serveur répond, l'IA dispose de 4 outils :
//
// - web_search : recherche DuckDuckGo (via crawl), liste {title, url, snippet}.
// - web_open : récupère une URL (cache 10 min), renvoie SEULEMENT les métadonnées
// (nb de lignes, taille, plan des titres) — pas le contenu.
// - web_read : lit une plage de lignes d'une URL déjà ouverte (offset + limit).
// - web_grep : recherche regex dans une URL déjà ouverte, lignes + contexte.
//
// Workflow attendu : web_open(url) → web_read/web_grep. Même logique que pi.
// ─── configuration & état ───────────────────────────────────────────────────
// crawl4aiURL renvoie l'URL du serveur Crawl4AI (config.env CRAWL4AI_URL), sans
// slash final. Vide si non configuré.
func crawl4aiURL() string {
u := strings.TrimSpace(ReadConfig()["CRAWL4AI_URL"])
return strings.TrimRight(u, "/")
}
// internetEnabled : accès internet actif = drapeau .internet_enabled présent ET
// une URL Crawl4AI configurée. Même modèle que agentEnabled() (agent.go).
func internetEnabled() bool {
if crawl4aiURL() == "" {
return false
}
_, err := os.Stat(internetFlag())
return err == nil
}
func setInternetEnabled(on bool) error {
_ = os.MkdirAll(JeanHome(), 0o755)
if on {
f, err := os.Create(internetFlag())
if err != nil {
return err
}
return f.Close()
}
if err := os.Remove(internetFlag()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// crawlReachable teste que le serveur Crawl4AI répond, avec un cache court (~30 s)
// pour ne pas ralentir chaque tour de chat. Un serveur configuré mais injoignable
// => les outils web ne sont pas proposés (« actif ET fonctionnel »).
var (
reachMu sync.Mutex
reachOK bool
reachURL string
reachWhen time.Time
)
const reachTTL = 30 * time.Second
func crawlReachable() bool {
base := crawl4aiURL()
if base == "" {
return false
}
reachMu.Lock()
defer reachMu.Unlock()
if base == reachURL && time.Since(reachWhen) < reachTTL {
return reachOK
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
ok := false
// Crawl4AI expose /health ; on tolère aussi une simple réponse HTTP sur la racine.
for _, path := range []string{"/health", "/"} {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
if err != nil {
continue
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode < 500 {
ok = true
break
}
}
reachOK, reachURL, reachWhen = ok, base, time.Now()
return ok
}
// ─── invocation crwl ────────────────────────────────────────────────────────
// autoDismissJS : dismisser conservateur de bandeaux cookies/consentement, exécuté
// dans la page. N'agit que sur des éléments qui RESSEMBLENT à une UI cookie
// (position fixed/sticky ou id/class cookie/consent/gdpr). Port direct de web.ts.
const autoDismissJS = `(() => { try {
const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;
const isOverlayish = (el) => {
try {
let cur = el;
for (let i = 0; i < 6 && cur; i++) {
const s = getComputedStyle(cur);
if (s.position === 'fixed' || s.position === 'sticky') return true;
const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();
if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;
cur = cur.parentElement;
}
} catch {}
return false;
};
const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]');
let clicked = 0;
for (const b of candidates) {
if (clicked >= 2) break;
const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();
if (!t || !TEXT.test(t)) continue;
if (b.offsetParent === null) continue;
if (!isOverlayish(b)) continue;
try { b.click(); clicked++; } catch {}
}
document.querySelectorAll('[id*="cookie" i],[id*="consent" i],[class*="cookie" i],[class*="consent" i],[id*="gdpr" i],[class*="gdpr" i],[id*="cmp" i],[class*="cmp" i]')
.forEach(el => { try {
const s = getComputedStyle(el);
if (s.position === 'fixed' || s.position === 'sticky') el.remove();
} catch {} });
document.documentElement.style.overflow = 'auto';
if (document.body) document.body.style.overflow = 'auto';
} catch {} })();`
type crwlOptions struct {
jsCode []string
waitFor string
pageTimeoutMs int
rawMarkdown bool // true => préfère raw_markdown (recherche) ; false => fit_markdown
}
// runCrwl appelle POST {base}/crawl et renvoie le markdown extrait. Port de web.ts.
func runCrwl(target string, opts crwlOptions) (string, error) {
base := crawl4aiURL()
if base == "" {
return "", fmt.Errorf("aucun serveur Crawl4AI configuré (CRAWL4AI_URL)")
}
params := map[string]any{}
if len(opts.jsCode) > 0 {
params["js_code"] = opts.jsCode
}
if opts.waitFor != "" {
params["wait_for"] = opts.waitFor
}
if opts.pageTimeoutMs > 0 {
params["page_timeout"] = opts.pageTimeoutMs
}
body := map[string]any{
"urls": []string{target},
"browser_config": map[string]any{
"type": "BrowserConfig",
"params": map[string]any{"headless": true},
},
"crawler_config": map[string]any{
"type": "CrawlerRunConfig",
"params": params,
},
}
buf, _ := json.Marshal(body)
timeout := 60 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/crawl", bytes.NewReader(buf))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("Crawl4AI injoignable (%s): %v", base, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("Crawl4AI HTTP %d: %s", resp.StatusCode, tailRunes(string(b), 300))
}
var data struct {
Results []crwlResult `json:"results"`
}
// La réponse peut être soit {results:[...]}, soit un objet unique. On décode
// d'abord la forme {results}, sinon on retombe sur un résultat unique.
raw, _ := io.ReadAll(resp.Body)
if jerr := json.Unmarshal(raw, &data); jerr != nil || len(data.Results) == 0 {
var single crwlResult
if json.Unmarshal(raw, &single) == nil {
data.Results = []crwlResult{single}
}
}
if len(data.Results) == 0 {
return "", fmt.Errorf("Crawl4AI : réponse vide")
}
r := data.Results[0]
if !r.Success {
msg := r.ErrorMessage
if msg == "" {
msg = "échec du crawl"
}
return "", fmt.Errorf("Crawl4AI : %s", tailRunes(msg, 300))
}
return r.markdown(opts.rawMarkdown), nil
}
// crwlResult modélise un résultat Crawl4AI. markdown peut être une string ou un
// objet {raw_markdown, fit_markdown} — on gère les deux via json.RawMessage.
type crwlResult struct {
Success bool `json:"success"`
ErrorMessage string `json:"error_message"`
Markdown json.RawMessage `json:"markdown"`
}
func (r crwlResult) markdown(preferRaw bool) string {
if len(r.Markdown) == 0 {
return ""
}
var s string
if json.Unmarshal(r.Markdown, &s) == nil {
return s
}
var obj struct {
Raw string `json:"raw_markdown"`
Fit string `json:"fit_markdown"`
}
if json.Unmarshal(r.Markdown, &obj) == nil {
if preferRaw {
if obj.Raw != "" {
return obj.Raw
}
return obj.Fit
}
if obj.Fit != "" {
return obj.Fit
}
return obj.Raw
}
return ""
}
// ─── URL & normalisation ────────────────────────────────────────────────────
// normalizeCrawlURL : github.com/owner/repo → README brut. Port de web.ts.
func normalizeCrawlURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
if u.Host == "github.com" {
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) == 2 {
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/README.md", parts[0], parts[1])
}
}
return raw
}
// normalizeLines : \r\n → \n, trim trailing, réduit les runs de lignes vides et
// les doublons consécutifs, retire les vides en tête/queue. Port de web.ts.
func normalizeLines(raw string) []string {
src := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
out := []string{}
prev := ""
blankRun := 0
trailSpace := regexp.MustCompile(`[ \t]+$`)
for _, l := range src {
l = trailSpace.ReplaceAllString(l, "")
if strings.TrimSpace(l) == "" {
blankRun++
if blankRun > 1 {
continue
}
out = append(out, "")
prev = ""
continue
}
blankRun = 0
if l == prev {
continue
}
out = append(out, l)
prev = l
}
for len(out) > 0 && out[0] == "" {
out = out[1:]
}
for len(out) > 0 && out[len(out)-1] == "" {
out = out[:len(out)-1]
}
return out
}
// ─── cache mémoire (TTL 10 min) ─────────────────────────────────────────────
type cacheEntry struct {
url string
lines []string
fetchedAt time.Time
}
var (
pageCacheMu sync.Mutex
pageCache = map[string]*cacheEntry{}
)
const pageCacheTTL = 10 * time.Minute
type fetchOptions struct {
force bool
actions []string
dismissPopups bool
waitFor string
}
func cacheKeyFor(u string, opts fetchOptions) string {
fp, _ := json.Marshal(map[string]any{"a": opts.actions, "d": opts.dismissPopups, "w": opts.waitFor})
if string(fp) == `{"a":null,"d":true,"w":""}` || string(fp) == `{"a":[],"d":true,"w":""}` {
return u
}
h := md5.Sum(fp)
return u + "#" + hex.EncodeToString(h[:])[:8]
}
// findCached : entrée de cache la plus récente pour une URL (toutes options).
func findCached(rawURL string) *cacheEntry {
u := normalizeCrawlURL(rawURL)
pageCacheMu.Lock()
defer pageCacheMu.Unlock()
var best *cacheEntry
for k, v := range pageCache {
if k == u || strings.HasPrefix(k, u+"#") {
if time.Since(v.fetchedAt) > pageCacheTTL {
continue
}
if best == nil || v.fetchedAt.After(best.fetchedAt) {
best = v
}
}
}
return best
}
func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
u := normalizeCrawlURL(rawURL)
key := cacheKeyFor(u, opts)
pageCacheMu.Lock()
cached := pageCache[key]
pageCacheMu.Unlock()
if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
return cached, nil
}
jsCode := []string{}
if opts.dismissPopups {
jsCode = append(jsCode, autoDismissJS)
}
jsCode = append(jsCode, opts.actions...)
pageTimeout := 0
if opts.waitFor != "" || len(opts.actions) > 0 {
pageTimeout = 45000
}
md, err := runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout})
if err != nil {
return nil, err
}
if strings.TrimSpace(md) == "" {
return nil, fmt.Errorf("page vide")
}
entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
pageCacheMu.Lock()
pageCache[key] = entry
pageCacheMu.Unlock()
return entry, nil
}
// ─── helpers de formatage ───────────────────────────────────────────────────
var headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*$`)
func extractOutline(lines []string) string {
var out []string
for i, l := range lines {
if m := headingRe.FindStringSubmatch(l); m != nil {
indent := strings.Repeat(" ", len(m[1])-1)
out = append(out, fmt.Sprintf("%5d | %s%s", i+1, indent, m[2]))
}
}
if len(out) == 0 {
return "(aucun titre markdown trouvé)"
}
return strings.Join(out, "\n")
}
func formatLines(lines []string, startLine int) string {
var b strings.Builder
for i, l := range lines {
fmt.Fprintf(&b, "%5d | %s\n", startLine+i, l)
}
return strings.TrimRight(b.String(), "\n")
}
func formatBytes(n int) string {
switch {
case n < 1024:
return fmt.Sprintf("%d B", n)
case n < 1024*1024:
return fmt.Sprintf("%.1f KB", float64(n)/1024)
default:
return fmt.Sprintf("%.2f MB", float64(n)/1024/1024)
}
}
// ─── recherche DuckDuckGo ───────────────────────────────────────────────────
type searchResult struct {
Title string
URL string
Snippet string
}
var (
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
wsRe = regexp.MustCompile(`\s+`)
numEntityRe = regexp.MustCompile(`&#(\d+);`)
ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`)
ddgLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
urlishRe = regexp.MustCompile(`^[\w.-]+\.[a-z]{2,}`)
)
func decodeHTMLEntities(s string) string {
s = strings.NewReplacer(
"&amp;", "&", "&lt;", "<", "&gt;", ">", "&quot;", `"`,
"&#39;", "'", "&#x2F;", "/", "&nbsp;", " ",
).Replace(s)
return numEntityRe.ReplaceAllStringFunc(s, func(m string) string {
var n int
fmt.Sscanf(m, "&#%d;", &n)
if n > 0 {
return string(rune(n))
}
return m
})
}
// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
func decodeUddg(raw string) string {
s := raw
if strings.HasPrefix(s, "//") {
s = "https:" + s
}
u, err := url.Parse(s)
if err != nil {
return s
}
if q := u.Query().Get("uddg"); q != "" {
if dec, e := url.QueryUnescape(q); e == nil {
return dec
}
}
return s
}
func duckduckgoSearch(query string, limit int) ([]searchResult, error) {
searchURL := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
md, err := runCrwl(searchURL, crwlOptions{rawMarkdown: true, pageTimeoutMs: 30000})
if err != nil {
return nil, err
}
if strings.Contains(md, "anomaly-modal") || strings.Contains(md, "anomaly.js") {
return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot")
}
var results []searchResult
lines := strings.Split(md, "\n")
for i := 0; i < len(lines) && len(results) < limit; i++ {
h := ddgHeadRe.FindStringSubmatch(lines[i])
if h == nil {
continue
}
title := strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(h[1]), "**", ""))
u := decodeUddg(h[2])
if title == "" || u == "" || strings.Contains(u, "duckduckgo.com") {
continue
}
snippet := ""
for j := i + 1; j < i+6 && j < len(lines); j++ {
ln := strings.TrimSpace(lines[j])
if ln == "" {
continue
}
for _, lm := range ddgLinkRe.FindAllStringSubmatch(ln, -1) {
text := strings.TrimSpace(lm[1])
if text == "" || strings.HasPrefix(text, "!") || urlishRe.MatchString(text) {
continue
}
snippet = strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(text), "**", ""))
break
}
if snippet != "" {
break
}
}
dup := false
for _, r := range results {
if r.URL == u {
dup = true
break
}
}
if dup {
continue
}
results = append(results, searchResult{Title: title, URL: u, Snippet: snippet})
}
return results, nil
}
// ─── définitions d'outils (schémas OpenAI, comme llm.go) ────────────────────
func webSearchTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_search",
Description: "Recherche sur le web via DuckDuckGo. Renvoie une liste classée de {title, url, snippet}. " +
"À utiliser quand l'utilisateur pose une question sans URL, cherche un outil/une bibliothèque, " +
"ou demande une information récente. À enchaîner avec web_open + web_read sur le meilleur résultat.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Requête (langage naturel ou mots-clés)"},
"limit": map[string]any{"type": "integer", "description": "Nb max de résultats (défaut 8, max 20)"},
},
"required": []string{"query"},
},
}}
}
func webOpenTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_open",
Description: "Récupère une URL et renvoie SEULEMENT les métadonnées (taille, nb de lignes, plan des titres). " +
"Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " +
"— les web_read / web_grep suivants le réutilisent.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "URL complète à récupérer"},
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."},
"actions": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."},
"dismiss_popups": map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."},
"wait_for": map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."},
},
"required": []string{"url"},
},
}}
}
func webReadTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_read",
Description: "Lit une plage de lignes d'une URL déjà ouverte avec web_open. Coût en tokens prévisible. " +
"Lignes 1-indexées, préfixées par leur numéro.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
"offset": map[string]any{"type": "integer", "description": "Ligne de départ (1-indexée, défaut 1)"},
"limit": map[string]any{"type": "integer", "description": "Nb de lignes (défaut 80, max 500)"},
},
"required": []string{"url"},
},
}}
}
func webGrepTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_grep",
Description: "Recherche regex dans une URL déjà ouverte avec web_open. Renvoie les lignes correspondantes " +
"avec contexte et numéros. Idéal quand la page est longue et qu'on connaît un mot-clé.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "URL précédemment ouverte avec web_open"},
"pattern": map[string]any{"type": "string", "description": "Motif regex (insensible à la casse)"},
"context": map[string]any{"type": "integer", "description": "Lignes de contexte autour de chaque match. Défaut 2."},
"max_matches": map[string]any{"type": "integer", "description": "Plafond de matches renvoyés. Défaut 30."},
},
"required": []string{"url", "pattern"},
},
}}
}
// ─── exécution des outils (appelée par le dispatch de llm.go) ───────────────
func toolWebSearch(args map[string]any) string {
query, _ := args["query"].(string)
limit := 8
if v, ok := args["limit"].(float64); ok {
limit = int(v)
}
if limit < 1 {
limit = 1
}
if limit > 20 {
limit = 20
}
results, err := duckduckgoSearch(query, limit)
if err != nil {
return "❌ Recherche échouée : " + err.Error()
}
if len(results) == 0 {
return fmt.Sprintf("Aucun résultat pour « %s »", query)
}
var b strings.Builder
fmt.Fprintf(&b, "# Recherche : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
for i, r := range results {
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)
}
return strings.TrimRight(b.String(), "\n")
}
func toolWebOpen(args map[string]any) string {
u, _ := args["url"].(string)
opts := fetchOptions{dismissPopups: true}
if v, ok := args["refresh"].(bool); ok {
opts.force = v
}
if v, ok := args["dismiss_popups"].(bool); ok {
opts.dismissPopups = v
}
if v, ok := args["wait_for"].(string); ok {
opts.waitFor = v
}
if arr, ok := args["actions"].([]any); ok {
for _, a := range arr {
if s, ok := a.(string); ok {
opts.actions = append(opts.actions, s)
}
}
}
entry, err := getPage(u, opts)
if err != nil {
return "❌ " + err.Error()
}
total := len(entry.lines)
chars := total
for _, l := range entry.lines {
chars += len(l)
}
return fmt.Sprintf("# Ouvert : %s\nTotal : %d lignes, %s (%d caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n%s\n```",
entry.url, total, formatBytes(chars), chars, extractOutline(entry.lines))
}
func toolWebRead(args map[string]any) string {
u, _ := args["url"].(string)
entry := findCached(u)
if entry == nil {
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
}
total := len(entry.lines)
offset := 1
if v, ok := args["offset"].(float64); ok {
offset = int(v)
}
if offset < 1 {
offset = 1
}
limit := 80
if v, ok := args["limit"].(float64); ok {
limit = int(v)
}
if limit < 1 {
limit = 1
}
if limit > 500 {
limit = 500
}
start := offset - 1
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
slice := entry.lines[start:end]
remaining := total - end
tail := " (fin de page)"
if remaining > 0 {
tail = fmt.Sprintf(" (%d de plus en dessous)", remaining)
}
return fmt.Sprintf("# %s\nLignes %d–%d sur %d%s\n\n```\n%s\n```",
entry.url, offset, end, total, tail, formatLines(slice, offset))
}
func toolWebGrep(args map[string]any) string {
u, _ := args["url"].(string)
pattern, _ := args["pattern"].(string)
entry := findCached(u)
if entry == nil {
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
}
re, err := regexp.Compile("(?i)" + pattern)
if err != nil {
return "❌ Regex invalide : " + err.Error()
}
ctx := 2
if v, ok := args["context"].(float64); ok {
ctx = int(v)
}
if ctx < 0 {
ctx = 0
}
maxMatches := 30
if v, ok := args["max_matches"].(float64); ok {
maxMatches = int(v)
}
if maxMatches < 1 {
maxMatches = 1
}
lines := entry.lines
var matchIdx []int
for i := 0; i < len(lines) && len(matchIdx) < maxMatches; i++ {
if re.MatchString(lines[i]) {
matchIdx = append(matchIdx, i)
}
}
if len(matchIdx) == 0 {
return fmt.Sprintf("# %s\nAucun match pour /%s/i", entry.url, pattern)
}
// Fusionne les fenêtres de contexte qui se chevauchent.
type rng struct{ s, e int }
var ranges []rng
for _, i := range matchIdx {
s := i - ctx
if s < 0 {
s = 0
}
e := i + ctx
if e > len(lines)-1 {
e = len(lines) - 1
}
if n := len(ranges); n > 0 && s <= ranges[n-1].e+1 {
if e > ranges[n-1].e {
ranges[n-1].e = e
}
} else {
ranges = append(ranges, rng{s, e})
}
}
var blocks []string
for _, r := range ranges {
blocks = append(blocks, "```\n"+formatLines(lines[r.s:r.e+1], r.s+1)+"\n```")
}
capped := ""
if len(matchIdx) == maxMatches {
capped = fmt.Sprintf(" (plafonné à %d)", maxMatches)
}
return fmt.Sprintf("# %s\n%d match(es) pour /%s/i%s\n\n%s",
entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
}
// ─── CLI : jean internet [on|off|status|url <url>] ──────────────────────────
func cmdInternet(args []string) error {
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "on":
if crawl4aiURL() == "" {
return fmt.Errorf("configure d'abord l'URL : jean internet url <url>")
}
if err := setInternetEnabled(true); err != nil {
return err
}
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)")
case "off":
if err := setInternetEnabled(false); err != nil {
return err
}
fmt.Println(green("[ok]") + " accès internet désactivé")
case "url":
if len(args) < 2 {
return fmt.Errorf("usage: jean internet url <url> (ex: http://localhost:11235)")
}
u := strings.TrimRight(strings.TrimSpace(args[1]), "/")
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
return err
}
reachMu.Lock()
reachURL = "" // invalide le cache de reachability
reachMu.Unlock()
fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
case "", "status", "list":
state := dim("off")
if internetEnabled() {
state = green("on")
}
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
u := crawl4aiURL()
if u == "" {
fmt.Printf(" serveur : %s — configure : jean internet url <url>\n", dim("(non configuré)"))
return nil
}
reach := red("injoignable")
if crawlReachable() {
reach = green("joignable")
}
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
default:
return fmt.Errorf("usage: jean internet [on|off|status|url <url>]")
}
return nil
}
+47 -10
View File
@@ -164,12 +164,22 @@ type Caps struct {
// Agent = mode agent actif : un seul interrupteur qui débloque TOUS les
// outils de l'IA (shell + skills). Un skill est un outil comme un autre.
Agent bool
// Internet = accès web actif (serveur Crawl4AI configuré + joignable) : ajoute
// les outils web_search/web_open/web_read/web_grep. Requiert aussi Agent.
Internet bool
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
// indépendant du mode agent. Voir MemMode.
Mem MemMode
}
// globalCaps reads the machine-wide config — the default when a request doesn't
// specify its own capabilities.
func globalCaps() Caps {
return Caps{Agent: agentEnabled()}
// Internet inclut la joignabilité du serveur Crawl4AI : « actif ET fonctionnel ».
// Ainsi le prompt système (tools.go) et les outils fournis (EnabledTools) sont
// gouvernés par la MÊME condition — sinon le prompt promet web_search alors que
// l'outil n'existe pas, et le modèle le tape en bash (command not found).
return Caps{Agent: agentEnabled(), Internet: internetEnabled() && crawlReachable(), Mem: memMode()}
}
// InjectSkills prepends context system messages to msgs: the decisive-agent
@@ -203,7 +213,18 @@ func InjectSkills(msgs []Message, caps Caps) []Message {
func EnabledTools(caps Caps) []Tool {
tools := []Tool{}
if caps.Agent {
tools = append(tools, bashTool(), editTool(), memSearchTool(), memReadTool(), memAddTool(), memEditTool())
tools = append(tools, bashTool(), editTool())
}
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
if caps.Mem != MemOff {
tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool())
}
// Outils web : seulement si le mode agent ET l'accès internet sont actifs.
// caps.Internet intègre déjà la joignabilité (globalCaps / override web.go),
// donc prompt et outils restent cohérents — pas de web_search halluciné.
if caps.Agent && caps.Internet {
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
}
return tools
}
@@ -307,12 +328,12 @@ type streamChunk struct {
// llama.cpp's "timings" appears on the final chunk and on intermediate
// /completion endpoint responses. Snake-case mapping per llama.cpp source.
Timings *struct {
PromptN int `json:"prompt_n"`
PromptMs float64 `json:"prompt_ms"`
PromptPerSecond float64 `json:"prompt_per_second"`
PredictedN int `json:"predicted_n"`
PredictedMs float64 `json:"predicted_ms"`
PredictedPerSec float64 `json:"predicted_per_second"`
PromptN int `json:"prompt_n"`
PromptMs float64 `json:"prompt_ms"`
PromptPerSecond float64 `json:"prompt_per_second"`
PredictedN int `json:"predicted_n"`
PredictedMs float64 `json:"predicted_ms"`
PredictedPerSec float64 `json:"predicted_per_second"`
} `json:"timings"`
// Chunk final (include_usage) : taille totale du prompt, hors choices.
Usage *struct {
@@ -495,10 +516,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if cur := toolCalls[0]; cur != nil {
key := "command"
switch cur.Function.Name {
case "mem_search":
case "mem_search", "web_search":
key = "query"
case "mem_read", "mem_add", "mem_edit", "edit":
key = "file"
case "web_open", "web_read", "web_grep":
key = "url"
}
if p := previewArg(cur.Function.Arguments, key); p != "" && p != lastPreview {
lastPreview = p
@@ -625,12 +648,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// nothing while a slow shell command runs and looks frozen.
label := ""
switch tc.Function.Name {
case "mem_search":
case "mem_search", "web_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit":
label, _ = args["file"].(string)
case "bash":
label, _ = args["command"].(string)
case "web_open", "web_read":
label, _ = args["url"].(string)
case "web_grep":
u, _ := args["url"].(string)
p, _ := args["pattern"].(string)
label = p + " @ " + u
}
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
@@ -692,6 +721,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
to = v
}
result = runShell(label, to)
case "web_search":
result = toolWebSearch(args)
case "web_open":
result = toolWebOpen(args)
case "web_read":
result = toolWebRead(args)
case "web_grep":
result = toolWebGrep(args)
default:
result = "[erreur] outil inconnu: " + tc.Function.Name
}
+22 -13
View File
@@ -4,6 +4,7 @@
// description) dans le .exe pour réduire les faux positifs antivirus. Régénère
// les .syso après avoir bumpé la version : `go generate ./...`
// (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest)
//
//go:generate goversioninfo -64 -o resource_windows_amd64.syso versioninfo.json
//go:generate goversioninfo -64 -arm -o resource_windows_arm64.syso versioninfo.json
package main
@@ -15,7 +16,7 @@ import (
"strings"
)
const Version = "0.2.16"
const Version = "0.2.17"
func main() {
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
@@ -56,6 +57,10 @@ func main() {
// interrupteur active TOUS les outils de l'IA (shell + skills). Les
// anciens noms restent acceptés comme alias rétro-compatibles.
mustExit(cmdAgent(args))
case "internet", "web-access":
mustExit(cmdInternet(args))
case "memory", "mem":
mustExit(cmdMemory(args))
case "serve":
mustExit(cmdServe(args))
case "test":
@@ -102,6 +107,8 @@ Presets:
Interaction:
chat [system-prompt] chat terminal streamé
web [PORT] UI web (défaut :8090) — chat + presets + mode agent
internet [on|off|status|url <url>] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep)
memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto)
Accès distant (ajean.link) :
link <token> enregistre le token et démarre le lien au relais (token = 1re fois / pour le changer)
@@ -177,15 +184,17 @@ func readEtcDefault() string {
return ""
}
func confPath() string { return filepath.Join(JeanHome(), "config.env") }
func presetsDir() string { return filepath.Join(JeanHome(), "configs") }
func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") }
func memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") }
func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") }
func confPath() string { return filepath.Join(JeanHome(), "config.env") }
func presetsDir() string { return filepath.Join(JeanHome(), "configs") }
func skillsDir() string { return filepath.Join(JeanHome(), "SKILLS") }
func memoryDir() string { return filepath.Join(JeanHome(), "MEMORY") }
func agentFlag() string { return filepath.Join(JeanHome(), ".agent_enabled") }
func internetFlag() string { return filepath.Join(JeanHome(), ".internet_enabled") }
// Anciens drapeaux séparés, conservés pour la migration vers le mode agent unifié.
func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") }
func legacyToolsFlag() string { return filepath.Join(JeanHome(), ".tools_enabled") }
func apiKeyPath() string { return filepath.Join(JeanHome(), ".api_key") }
func apiKeyPath() string { return filepath.Join(JeanHome(), ".api_key") }
func serviceName() string {
if n := os.Getenv("JEAN_SERVICE"); n != "" {
return n
@@ -202,12 +211,12 @@ func col(code, s string) string {
}
return "\033[" + code + "m" + s + "\033[0m"
}
func bold(s string) string { return col("1", s) }
func cyan(s string) string { return col("1;36", s) }
func green(s string) string { return col("32", s) }
func red(s string) string { return col("31", s) }
func dim(s string) string { return col("2", s) }
func yellow(s string) string { return col("33", s) }
func bold(s string) string { return col("1", s) }
func cyan(s string) string { return col("1;36", s) }
func green(s string) string { return col("32", s) }
func red(s string) string { return col("31", s) }
func dim(s string) string { return col("2", s) }
func yellow(s string) string { return col("33", s) }
func magenta(s string) string { return col("35", s) }
// trimSplit splits and drops empty tokens.
+70
View File
@@ -330,6 +330,76 @@ func migrateSkillsToMemory() {
_ = os.WriteFile(flag, []byte(fmt.Sprintf("%d skills migrés\n", migrated)), 0o644)
}
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
// mode agent (shell). Trois modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
type MemMode string
const (
MemOff MemMode = "off"
MemOnDemand MemMode = "ondemand"
MemAlways MemMode = "always"
)
// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
// actuel). Toute valeur inconnue retombe sur always.
func memMode() MemMode {
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
case "off", "0", "false", "none", "no", "non":
return MemOff
case "ondemand", "on-demand", "demand", "manual", "manuel":
return MemOnDemand
default: // "always", "auto", "" et inconnus
return MemAlways
}
}
// setMemMode persiste le mode mémoire dans config.env.
func setMemMode(m MemMode) error {
return SetConfigKey("MEM_MODE", string(m))
}
// cmdMemory : jean memory [off|ondemand|always|status]
func cmdMemory(args []string) error {
sub := ""
if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0]))
}
label := map[MemMode]string{
MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
}
switch sub {
case "off", "none", "0", "false":
if err := setMemMode(MemOff); err != nil {
return err
}
case "ondemand", "on-demand", "demand", "manual", "manuel":
if err := setMemMode(MemOnDemand); err != nil {
return err
}
case "always", "auto", "on":
if err := setMemMode(MemAlways); err != nil {
return err
}
case "", "status", "list":
m := memMode()
fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
pages := MemList()
fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
return nil
default:
return fmt.Errorf("usage: jean memory [off|ondemand|always|status]")
}
m := memMode()
fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
return nil
}
// memorySystemPrompt liste les pages mémoire à injecter quand le mode agent est
// actif, pour que l'IA sache ce qu'elle a déjà retenu.
func memorySystemPrompt(caps Caps) string {
Binary file not shown.
Binary file not shown.
+48 -20
View File
@@ -7,6 +7,7 @@ import (
"os/exec"
"os/user"
"runtime"
"strconv"
"strings"
"time"
)
@@ -24,10 +25,12 @@ const (
// The per-tool "Outil disponible" sections live in machine/skills prompts so
// they only appear when the matching feature is on.
func baseSystemPrompt(caps Caps) string {
// No tool access → no agentic preamble. A plain chat model told to "call
// tools immediately" hallucinates textual tool calls (e.g. default_api:bash)
// that leak into the answer. Let the user's own system prompt stand alone.
if !caps.Agent {
hasMem := caps.Mem != MemOff
// No tool access at all → no agentic preamble. A plain chat model told to
// "call tools immediately" hallucinates textual tool calls (e.g.
// default_api:bash) that leak into the answer. Let the user's own system
// prompt stand alone. (Internet requiert l'agent, donc pas testé ici.)
if !caps.Agent && !hasMem {
return ""
}
var b strings.Builder
@@ -36,28 +39,53 @@ func baseSystemPrompt(caps Caps) string {
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
// puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
// mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
b.WriteString("You are jean, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\n")
b.WriteString("Tools:\n")
for _, l := range []string{
"bash — run a shell command on this machine (inspect files, processes, logs, run scripts).",
"edit — patch a file by exact replacement (old → new, old must be unique).",
"mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.",
} {
b.WriteString("- " + l + "\n")
b.WriteString("You are jean, an expert assistant operating directly on this machine with real tools.")
if caps.Mem == MemAlways {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
for _, l := range []string{
"When the user tells you to remember something, or shares a preference, fact, decision, or how-to worth keeping, save it with mem_add (or mem_edit to update an existing page) — do it on your own, without being asked.",
"Before doing any task or answering, first call mem_search to see whether your memory already holds the answer or how to do it, then mem_read the best page. Do this even when the request has new specifics like a name, a place or a value — your saved method still applies, only the parameter changes.",
} {
b.WriteString("- " + l + "\n")
b.WriteString("\n\nTools:\n")
if caps.Agent {
b.WriteString("- bash — run a shell command on this machine (inspect files, processes, logs, run scripts).\n")
b.WriteString("- edit — patch a file by exact replacement (old → new, old must be unique).\n")
}
if hasMem {
b.WriteString("- mem_search / mem_read / mem_add / mem_edit — your persistent Markdown memory under MEMORY/.\n")
}
// Politique d'usage de la mémoire selon le mode.
switch caps.Mem {
case MemAlways:
b.WriteString("\nManaging your memory is part of the job, not optional:\n")
b.WriteString("- When the user tells you to remember something, or shares a preference, fact, decision, or how-to worth keeping, save it with mem_add (or mem_edit to update an existing page) — do it on your own, without being asked.\n")
b.WriteString("- Before doing any task or answering, first call mem_search to see whether your memory already holds the answer or how to do it, then mem_read the best page. Do this even when the request has new specifics like a name, a place or a value — your saved method still applies, only the parameter changes.\n")
case MemOnDemand:
b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
}
if caps.Agent {
b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
if caps.Mem == MemAlways {
b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n")
}
}
if caps.Internet {
b.WriteString("\nWeb access (Crawl4AI): web_search (DuckDuckGo), web_open (fetch a URL → metadata + outline), web_read (read a line range of an opened URL), web_grep (regex in an opened URL). Workflow: web_open first, then web_read/web_grep.\n")
year := time.Now().Format("2006")
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, fixtures, 'since when') call web_search BEFORE writing any date or version. Your answer must match the dates/facts you actually read.\n")
b.WriteString("SEARCH QUERY YEAR RULE: today is in " + year + ". If your query includes a year, use ONLY " + year + " — NEVER write a past year like " + prevYear(year) + " that you remember from training; it silently biases results toward stale pages. Default: put no year at all and let the freshest result win. Don't hedge ('probably', 'I think') about a fact a tool can verify — search instead.\n")
}
b.WriteString("\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
b.WriteString("Before answering any question about yourself or this machine, always call mem_search first — even trivial-seeming ones. Testing with a tool never replaces this: memory may hold context the tool won't reveal. Search memory, then verify, then answer.\n")
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
return b.String()
}
// prevYear returns the year before the given "2006"-formatted year string, used
// to name explicitly the stale year the model must NOT put in search queries.
func prevYear(year string) string {
n, err := strconv.Atoi(year)
if err != nil {
return year
}
return strconv.Itoa(n - 1)
}
// machineSystemPrompt returns a short briefing about the host the model is
// running on, so that when machine access is enabled it knows *which* machine
// run_shell acts upon (and doesn't claim it has no access to "your PC").
+71 -7
View File
@@ -245,13 +245,30 @@ button:disabled{opacity:.5;cursor:not-allowed}
</details>
<details><summary>Mode agent <span id="agent-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
<label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent</label>
<div class="muted" style="font-size:11px">⚠️ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) <b>et</b> la <b>mémoire</b> — l'IA peut inspecter, lancer des scripts et lire/écrire ses pages mémoire (timeout 30s, max 300s)</div>
<div class="muted" style="font-size:11px">⚠️ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, lire/éditer des fichiers (timeout 30s, max 300s). La mémoire se règle séparément ci-dessous.</div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="toollimit-toggle" onchange="toggleToolLimit()"><span class="slider"></span></span> limiter les appels d'outils par tour</label>
<div class="muted" style="font-size:11px">activé par défaut : coupe l'IA après ~8 appels d'outils (message <code>[stop: trop d'appels d'outils]</code>). Désactive-le si tu veux la laisser enchaîner sans plafond (l'anti-boucle reste actif).</div>
<div class="skillshead"><span>Mémoire <span id="mem-count" class="mem-count"></span></span><button class="addbtn" onclick="event.preventDefault();event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button></div>
<label class="switchrow" style="justify-content:space-between;gap:10px"><span>Mémoire</span>
<select id="mem-mode" onchange="setMemMode()" style="background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font:inherit;font-size:12px">
<option value="always">auto (proactive)</option>
<option value="ondemand">sur demande</option>
<option value="off">désactivée</option>
</select>
</label>
<div id="mem-mode-desc" class="muted" style="font-size:11px"></div>
<div class="skillshead"><span>Pages <span id="mem-count" class="mem-count"></span></span><button class="addbtn" onclick="event.preventDefault();event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button></div>
<input id="mem-search" class="mem-search" type="search" placeholder="filtrer la mémoire…" oninput="renderMemList()" style="display:none">
<div id="mem-list" class="mem-list muted" style="font-size:12px"></div>
</details>
<details><summary>Accès internet <span id="internet-badge" style="margin-left:auto;margin-right:12px;font-size:10px"></span></summary>
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
<div class="muted" style="font-size:11px">nécessite le <b>mode agent</b>. Ajoute les outils <b>web_search / web_open / web_read / web_grep</b> via un serveur <b>Crawl4AI</b>. Actifs seulement si le serveur répond.</div>
<div class="row" style="margin-top:6px">
<input id="crawl-url" type="text" placeholder="http://localhost:11235" style="flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px;font:inherit" onchange="saveCrawlUrl()">
<button onclick="saveCrawlUrl()">enregistrer</button>
</div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</details>
<details><summary>System prompt</summary>
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" style="width:100%;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px;font:inherit;min-height:70px;resize:vertical"></textarea>
</details>
@@ -540,6 +557,7 @@ async function loadAgent(){
document.getElementById('agent-badge').innerHTML = on
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
document.getElementById('brand').classList.toggle('agent', on);
if(s.mem_mode){ document.getElementById('mem-mode').value = s.mem_mode; renderMemModeDesc(s.mem_mode); }
memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name));
memShown = MEM_PAGE;
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
@@ -588,7 +606,44 @@ async function toggleToolLimit(){
await jpost('/api/agent/tool-limit',{on});
loadAgent();
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent()]); }
// Mode mémoire (3 états) — indépendant du mode agent.
const MEM_DESC={
always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.',
ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).',
off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.'
};
function renderMemModeDesc(m){ const d=document.getElementById('mem-mode-desc'); if(d) d.textContent=MEM_DESC[m]||''; }
async function setMemMode(){
const mode=document.getElementById('mem-mode').value;
const r=await jpost('/api/memory',{mode});
renderMemModeDesc(r.mode||mode);
}
// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte.
let internetOn=false;
function renderInternet(s){
internetOn = !!s.enabled;
document.getElementById('internet-toggle').checked = internetOn;
if(document.activeElement !== document.getElementById('crawl-url'))
document.getElementById('crawl-url').value = s.url || '';
document.getElementById('internet-badge').innerHTML = internetOn
? '<span class="tag ok">on</span>' : '<span class="tag" style="opacity:.6">off</span>';
const st=document.getElementById('internet-status');
if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; }
else if(s.reachable){ st.innerHTML='✅ serveur joignable — outils web actifs'; }
else { st.innerHTML='⚠️ serveur injoignable — les outils web ne seront pas proposés'; }
}
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
async function toggleInternet(){
const on=document.getElementById('internet-toggle').checked;
const url=document.getElementById('crawl-url').value.trim();
if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; }
renderInternet(await jpost('/api/internet',{enabled:on, url}));
}
async function saveCrawlUrl(){
const url=document.getElementById('crawl-url').value.trim();
renderInternet(await jpost('/api/internet',{url}));
}
async function loadAll(){ await Promise.all([loadStatus(),loadVram(),loadCfg(),loadPresets(),loadAgent(),loadInternet()]); }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
function openBenchModal(){ document.getElementById('bench-modal').style.display = 'flex'; }
function closeBenchModal(){ document.getElementById('bench-modal').style.display = 'none'; }
@@ -917,15 +972,24 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCo
// Render a tool call as its own conversation message: the command the model
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
const isShell = tu.name==='bash';
const isEdit = tu.name==='edit';
let lbl = isShell ? 'terminal' : (isEdit ? 'édition' : 'mémoire');
// Métadonnées d'affichage par outil : nom court + en-tête avec icône. Les outils
// web (web_search/open/read/grep) doivent afficher 🌐, pas le fallback mémoire.
const META = {
bash: {lbl:'terminal', head:'⚙️ commande'},
edit: {lbl:'édition', head:'✏️ édition'},
web_search: {lbl:'recherche', head:'🌐 recherche web'},
web_open: {lbl:'page web', head:'🌐 ouverture'},
web_read: {lbl:'page web', head:'🌐 lecture'},
web_grep: {lbl:'page web', head:'🌐 recherche'},
};
const meta = META[tu.name] || {lbl:'mémoire', head:'🧠 mémoire'};
let lbl = meta.lbl;
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
setLabel(el, lbl);
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
head.textContent = isShell ? '⚙️ commande' : (isEdit ? '✏️ édition' : '🧠 mémoire');
head.textContent = meta.head;
body.appendChild(head);
if(tu.label){
const pre=document.createElement('pre'); pre.className='tool-cmd';
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 2,
"Patch": 16,
"Patch": 17,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 2,
"Patch": 16,
"Patch": 17,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
"ProductVersion": "0.2.16",
"ProductVersion": "0.2.17",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+74 -2
View File
@@ -86,6 +86,8 @@ func newWebMux() *http.ServeMux {
api("/api/agent", handleAgent)
api("/api/agent/toggle", handleAgentToggle)
api("/api/agent/tool-limit", handleToolLimitToggle)
api("/api/internet", handleInternet)
api("/api/memory", handleMemoryMode)
// Alias rétro-compat : l'ancien portail ajean.link (dépôt jean-relay) pilote
// encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E.
// On les mappe sur le mode agent unifié le temps que le portail soit mis à jour.
@@ -437,7 +439,36 @@ func handleAgent(w http.ResponseWriter, r *http.Request) {
for _, p := range pages {
out = append(out, map[string]any{"name": p.Name, "desc": p.Title})
}
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "pages": out, "skills": out})
sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "tool_limit": toolLimitEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out})
}
// handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always).
//
// GET → {mode}
// POST {mode} → persiste MEM_MODE
func handleMemoryMode(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Mode string `json:"mode"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
// On normalise via memMode() en réinjectant la valeur : toute entrée
// inconnue retombe sur "always", donc on valide en passant par le parseur.
m := MemAlways
switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) {
case MemOff:
m = MemOff
case MemOnDemand:
m = MemOnDemand
case MemAlways:
m = MemAlways
}
if err := setMemMode(m); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())})
}
// handleToolLimitToggle active/désactive le plafond d'appels d'outils par tour
@@ -470,6 +501,42 @@ func handleAgentToggle(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "enabled": agentEnabled()})
}
// handleInternet pilote l'accès web de l'IA (serveur Crawl4AI).
//
// GET → {enabled, url, reachable}
// POST {enabled, url} → enregistre CRAWL4AI_URL + le drapeau .internet_enabled
func handleInternet(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var req struct {
Enabled *bool `json:"enabled"`
URL *string `json:"url"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if req.URL != nil {
u := strings.TrimRight(strings.TrimSpace(*req.URL), "/")
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
reachMu.Lock()
reachURL = "" // invalide le cache de reachability
reachMu.Unlock()
}
if req.Enabled != nil {
if err := setInternetEnabled(*req.Enabled); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
}
sendJSON(w, 200, map[string]any{
"ok": true,
"enabled": internetEnabled(),
"url": crawl4aiURL(),
"reachable": crawlReachable(),
})
}
func handleSkill(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" {
@@ -640,6 +707,8 @@ type chatReq struct {
Agent *bool `json:"agent"`
Tools *bool `json:"tools"`
Skills *bool `json:"skills"`
// Override par requête de l'accès internet (outils web). nil = config machine.
Internet *bool `json:"internet"`
}
// sseHeartbeat garde la réponse SSE active en écrivant un commentaire (`: ping`,
@@ -695,6 +764,10 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any)
// rétro-compat : anciens clients qui envoyaient deux drapeaux séparés
caps.Agent = (body.Tools != nil && *body.Tools) || (body.Skills != nil && *body.Skills)
}
if body.Internet != nil {
// On garde la cohérence prompt/outils : internet demandé ET serveur joignable.
caps.Internet = *body.Internet && crawlReachable()
}
msgs := InjectSkills(body.Messages, caps)
extra, _ := runChat(ctx, msgs, body.Temperature, caps, func(ev StreamEvent) bool {
if ev.Err != nil {
@@ -752,4 +825,3 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
}
runChatStream(r.Context(), body, emit)
}