gemcode 0.3.82__py3-none-any.whl → 0.3.86__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.
gemcode/agent.py CHANGED
@@ -103,14 +103,15 @@ def _chain_before_model_callbacks(*callbacks):
103
103
 
104
104
  def _load_gemini_md(project_root: Path) -> str:
105
105
  """
106
- Load GEMINI.md / .gemcode/NOTES.md from a interactive CLI–style hierarchy.
106
+ Load project instruction markdown / .gemcode/NOTES.md from a interactive CLI–style hierarchy.
107
107
 
108
108
  Priority (later entries override earlier ones, all are concatenated):
109
109
  1. ~/.gemcode/GEMINI.md — user-global instructions (all projects)
110
- 2. Walk UP from project_root: each directory's GEMINI.md / .gemcode/GEMINI.md
110
+ 2. Walk UP from project_root: each directory's `gemcode.md` / `GEMINI.md`
111
111
  (org-level files at higher dirs, project-level at project_root)
112
- 3. project_root/GEMINI.md — the primary project instructions
113
- 4. project_root/.gemcode/GEMINI.md alternative location
112
+ 3. project_root/gemcode.md — the primary project instructions
113
+ 4. project_root/GEMINI.md backward-compatible legacy location
114
+ 5. project_root/.gemcode/GEMINI.md — alternative location
114
115
  5. project_root/.gemcode/notes.md — agent auto-generated notes (read-only context)
115
116
 
116
117
  Max total: 80,000 chars. Each file is capped at 30,000 chars.
@@ -118,7 +119,14 @@ def _load_gemini_md(project_root: Path) -> str:
118
119
  """
119
120
  import re
120
121
 
121
- _NAMES = ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md")
122
+ _NAMES = (
123
+ "gemcode.md",
124
+ "GEMCODE.md",
125
+ "GEMINI.md",
126
+ "gemini.md",
127
+ ".gemcode/GEMINI.md",
128
+ ".gemcode/gemini.md",
129
+ )
122
130
  _FILE_CAP = 30_000
123
131
  _TOTAL_CAP = 80_000
124
132
  _COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
@@ -162,8 +170,15 @@ def _load_gemini_md(project_root: Path) -> str:
162
170
  for name in _NAMES:
163
171
  _add(ancestor / name)
164
172
 
165
- # 3+4. Project-root level instructions (primary location)
166
- for name in ("GEMINI.md", "gemini.md", ".gemcode/GEMINI.md", ".gemcode/gemini.md"):
173
+ # 3+5. Project-root level instructions (primary location + compatibility)
174
+ for name in (
175
+ "gemcode.md",
176
+ "GEMCODE.md",
177
+ "GEMINI.md",
178
+ "gemini.md",
179
+ ".gemcode/GEMINI.md",
180
+ ".gemcode/gemini.md",
181
+ ):
167
182
  _add(project_root / name)
168
183
 
169
184
  # 5. Agent-generated notes (informational context, not instructions)
@@ -303,6 +318,25 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
303
318
  except Exception:
304
319
  curated_section = ""
305
320
 
321
+ # ── VeoMem recall (optional) ─────────────────────────────────────────────
322
+ veomem_section = ""
323
+ try:
324
+ t = getattr(cfg, "_veomem_wakeup_text", None)
325
+ if isinstance(t, str) and t.strip():
326
+ veomem_section = (
327
+ "\n\n## VeoMem recall (auto-captured, progressive)\n"
328
+ "This section is automatically generated from prior tool usage and summaries. "
329
+ "Treat it as helpful context; do not restate it verbatim to the user.\n"
330
+ "If you need deeper details about a specific prior observation, use the "
331
+ "3-step retrieval flow with tools:\n"
332
+ "- `veomem_search(query=...)` → get an index of relevant observation IDs\n"
333
+ "- `veomem_timeline(id=...)` → get compact neighbors around an anchor ID\n"
334
+ "- `veomem_get_observations(ids=...)` → fetch full text for selected IDs\n"
335
+ f"{t.strip()}\n"
336
+ )
337
+ except Exception:
338
+ veomem_section = ""
339
+
306
340
  return f"""## Runtime facts (authoritative for this session)
307
341
  - **Today's date:** {today}
308
342
  - **Project root** — every filesystem tool path is relative to: `{root}`
@@ -316,7 +350,7 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
316
350
  {kaira_section}
317
351
  - **UI banner** phrases like "GemCode Pro" are terminal marketing, not a separate API tier.
318
352
  - **Env toggles** (`GEMCODE_ENABLE_COMPUTER_USE`, `GEMCODE_MODEL`, etc.) affect only the OS process that launched gemcode. Pasting `VAR=1` in chat does NOT reconfigure a running session—tell the user to export in their shell, use project `.env`, or restart the CLI.
319
- - **Working in subfolders** — call `list_directory(\"Desktop\")`, `glob_files(\"**/query.ts\")`, `read_file(\"testing/ai-edtech-app/src/app/page.tsx\")` directly. Never claim access is blocked unless a tool returned an explicit error.{git_section}{curated_section}"""
353
+ - **Working in subfolders** — call `list_directory(\"Desktop\")`, `glob_files(\"**/query.ts\")`, `read_file(\"testing/ai-edtech-app/src/app/page.tsx\")` directly. Never claim access is blocked unless a tool returned an explicit error.{git_section}{curated_section}{veomem_section}"""
320
354
 
321
355
 
322
356
  def _build_memory_section(cfg: GemCodeConfig) -> str:
gemcode/callbacks.py CHANGED
@@ -525,6 +525,26 @@ def make_after_tool_callback(cfg: GemCodeConfig):
525
525
  except Exception:
526
526
  pass
527
527
 
528
+ # ── VeoMem (optional): auto-capture tool usage ────────────────────────
529
+ try:
530
+ from gemcode.veomem_bridge import record_tool_use
531
+ sid = None
532
+ try:
533
+ if tool_context is not None and getattr(tool_context, "session", None) is not None:
534
+ sid = getattr(tool_context.session, "id", None)
535
+ except Exception:
536
+ sid = None
537
+ record_tool_use(
538
+ cfg.project_root,
539
+ session_id=str(sid) if sid else None,
540
+ tool_name=name,
541
+ args=args or {},
542
+ result=tool_response if isinstance(tool_response, dict) else {},
543
+ paths=(st.get(_RISK_FILES_TOUCHED, []) or []) if isinstance(st, dict) else None,
544
+ )
545
+ except Exception:
546
+ pass
547
+
528
548
  if _maybe_tool_summary_enabled():
529
549
  summary: dict[str, Any] = {
530
550
  "phase": "tool_result",
gemcode/cli.py CHANGED
@@ -7,6 +7,7 @@ import asyncio
7
7
  import getpass
8
8
  import json
9
9
  import os
10
+ import re
10
11
  import sys
11
12
  import uuid
12
13
  import warnings
@@ -129,18 +130,30 @@ def _initialize_gemcode_project(cfg: GemCodeConfig) -> None:
129
130
  """
130
131
  root = cfg.project_root.resolve()
131
132
  gem_dir = root / ".gemcode"
133
+ gemcode_md = root / "gemcode.md"
132
134
  already_there = gem_dir.is_dir()
133
135
  try:
134
136
  gem_dir.mkdir(parents=True, exist_ok=True)
135
137
  except OSError as e:
136
138
  print(f"[gemcode] warning: could not create {gem_dir}: {e}", file=sys.stderr)
137
139
  return
140
+ if not gemcode_md.exists():
141
+ try:
142
+ gemcode_md.write_text(
143
+ "# Project instructions\n\n"
144
+ "- Describe the project purpose here.\n"
145
+ "- Add build, test, and lint commands.\n"
146
+ "- Add architecture notes and conventions GemCode should follow.\n",
147
+ encoding="utf-8",
148
+ )
149
+ except OSError as e:
150
+ print(f"[gemcode] warning: could not create {gemcode_md}: {e}", file=sys.stderr)
138
151
  if not already_there:
139
152
  print(
140
153
  "\n── GemCode · Project folder ready ──\n"
141
154
  f" Workspace: {root}\n"
142
155
  f" Config & session data: {gem_dir}/\n"
143
- " Optional: add GEMINI.md in the repo root for project context.\n"
156
+ f" Project instructions: {gemcode_md.name}\n"
144
157
  "── Ready. ──\n",
145
158
  file=sys.stderr,
146
159
  )
@@ -262,6 +275,100 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
262
275
  "GemCode CLI is running. Type your prompt and press Enter. (Ctrl+D to exit)",
263
276
  file=sys.stderr,
264
277
  )
278
+
279
+ def _looks_like_new_skill_request(s: str) -> bool:
280
+ t = (s or "").strip().lower()
281
+ if not t or t.startswith("/"):
282
+ return False
283
+ # Natural language trigger: "make/create/build a new skill/gemskill"
284
+ return bool(
285
+ re.search(r"\b(new|create|make|build)\b", t)
286
+ and re.search(r"\b(gem\s*skill|gemskill|skill)\b", t)
287
+ )
288
+
289
+ def _prompt_nonempty(label: str, default: str | None = None) -> str:
290
+ while True:
291
+ try:
292
+ v = input(label).strip()
293
+ except EOFError:
294
+ return default or ""
295
+ if v:
296
+ return v
297
+ if default is not None:
298
+ return default
299
+
300
+ def _wizard_create_gemskill() -> str | None:
301
+ """
302
+ Interactive CLI wizard that collects a GemSkill spec, then returns a single
303
+ model prompt instructing the agent to generate the skill folder/files.
304
+ """
305
+ if not (hasattr(sys.stdin, "isatty") and sys.stdin.isatty()):
306
+ return None
307
+ print("\n── GemSkill wizard ──", file=sys.stderr)
308
+ print("We'll create a new skill under `.gemcode/skills/<name>/`.\n", file=sys.stderr)
309
+ name = _prompt_nonempty("skill name (kebab-case, e.g. api-review): ")
310
+ name = (name or "").strip().lower()
311
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,63}", name):
312
+ print("Invalid name. Use lowercase letters/numbers/hyphens (max 64 chars).", file=sys.stderr)
313
+ return None
314
+ desc = _prompt_nonempty("one-line description: ")
315
+ when = _prompt_nonempty("when should it be used (1-2 sentences)? ")
316
+ inputs = _prompt_nonempty("inputs it should accept (bullets or short text): ", default="User request + $ARGUMENTS")
317
+ outputs = _prompt_nonempty("expected output format (short): ", default="Concise checklist + actionable steps")
318
+ tools_pref = _prompt_nonempty(
319
+ "tools: (a)uto, (r)ead-only, (w)eb-research-heavy? [a]: ",
320
+ default="a",
321
+ ).strip().lower()
322
+ use_web = tools_pref.startswith("w")
323
+ read_only = tools_pref.startswith("r")
324
+ examples = _prompt_nonempty("example command(s) user will type (optional): ", default="")
325
+
326
+ root = cfg.project_root.resolve()
327
+ skill_dir = root / ".gemcode" / "skills" / name
328
+ skill_md = skill_dir / "SKILL.md"
329
+
330
+ prompt_parts: list[str] = [
331
+ "You are creating a **GemCode GemSkill**. Generate a new skill folder and files.\n\n",
332
+ "## Target\n",
333
+ f"- Project root: {root}\n",
334
+ f"- Skill directory: {skill_dir}\n",
335
+ f"- Primary file: {skill_md}\n\n",
336
+ "## Requirements\n",
337
+ f"- Skill name: {name}\n",
338
+ f"- Description: {desc}\n",
339
+ f"- When to use: {when}\n",
340
+ f"- Inputs: {inputs}\n",
341
+ f"- Output expectations: {outputs}\n",
342
+ ]
343
+ if examples:
344
+ prompt_parts.append(f"- Example invocations: {examples}\n")
345
+ prompt_parts.extend(
346
+ [
347
+ "\n",
348
+ "- Write `SKILL.md` with YAML frontmatter using **multiline-friendly** fields when needed.\n",
349
+ "- Include: Purpose, When to use, When NOT to use (guardrails), Inputs, Output format, Workflow, Examples.\n",
350
+ "- Make it **token-efficient**: prefer short checklists and explicit decision gates.\n",
351
+ "- Avoid vague 'ALWAYS trigger' language; provide precise triggers.\n",
352
+ "- If you need templates/checklists, create supporting files in a `references/` subfolder and keep them small.\n",
353
+ "- Do not create CLAUDE.md/AGENTS.md or other vendor-specific files.\n\n",
354
+ "## Tooling / research policy\n",
355
+ (
356
+ "- You MAY use web research to find best practices, but only if it materially improves the skill.\n"
357
+ if use_web
358
+ else "- Avoid web research unless strictly necessary.\n"
359
+ ),
360
+ ("- Operate in read-only mode: do not write files.\n" if read_only else "- You are allowed to write the skill files.\n"),
361
+ "\n",
362
+ "## Execution steps\n",
363
+ "1. Create the skill directory if missing.\n",
364
+ "2. Write `SKILL.md` (and any supporting files).\n",
365
+ "3. Validate the YAML frontmatter parses and the skill is usable.\n",
366
+ f"4. Print a short confirmation: created files + how to invoke (e.g. `/skills list`, `/{name} ...`, `/gemskill {name}`).\n",
367
+ ]
368
+ )
369
+ prompt = "".join(prompt_parts)
370
+ return prompt
371
+
265
372
  while True:
266
373
  try:
267
374
  raw = input("> ")
@@ -274,6 +381,12 @@ async def _run_repl(cfg: GemCodeConfig, session_id: str, *, use_mcp: bool) -> No
274
381
  if prompt_text in (":q", "quit", "exit", "/exit"):
275
382
  break
276
383
 
384
+ # Natural language shortcut: "I want to make a new skill" => wizard.
385
+ if _looks_like_new_skill_request(prompt_text):
386
+ wizard_prompt = _wizard_create_gemskill()
387
+ if wizard_prompt:
388
+ prompt_text = wizard_prompt
389
+
277
390
  cfg.session_skill_expand_session_id = session_id
278
391
  slash = await process_repl_slash(
279
392
  cfg=cfg,
gemcode/config.py CHANGED
@@ -232,7 +232,9 @@ class GemCodeConfig:
232
232
  # Deep research model id used when routing selects deep research.
233
233
  model_deep_research: str = field(
234
234
  default_factory=lambda: os.environ.get(
235
- "GEMCODE_MODEL_DEEP_RESEARCH", "travel_explore"
235
+ # Leave empty by default so routing falls back to `cfg.model` unless the
236
+ # user explicitly configures a deep-research-capable model id.
237
+ "GEMCODE_MODEL_DEEP_RESEARCH", ""
236
238
  )
237
239
  )
238
240
 
gemcode/ide_stdio.py CHANGED
@@ -322,7 +322,13 @@ async def run_stdio_loop() -> int:
322
322
  continue
323
323
  out_text = "".join(txt_parts).strip()
324
324
  if out_text:
325
- emitter.send(make_event(event="text", id=req_id, text=out_text))
325
+ # Pseudo-streaming: emit deltas so IDE chat can feel alive even though
326
+ # we only have the final assistant text today.
327
+ emitter.send(make_event(event="text_start", id=req_id))
328
+ chunk_size = 450
329
+ for i in range(0, len(out_text), chunk_size):
330
+ emitter.send(make_event(event="text_delta", id=req_id, text=out_text[i : i + chunk_size]))
331
+ emitter.send(make_event(event="text_end", id=req_id))
326
332
  emitter.send(make_response(id=req_id, ok=True, session=session_id))
327
333
 
328
334
  finally:
@@ -189,5 +189,36 @@ class GemCodeTerminalHooksPlugin(BasePlugin):
189
189
  {"phase": "post_turn_hook", "ok": False, "error": str(e)},
190
190
  )
191
191
 
192
+ # ── VeoMem (optional): store final assistant summary ───────────────────
193
+ try:
194
+ # Best-effort: extract last assistant text from session events.
195
+ txt = ""
196
+ try:
197
+ events = callback_context.session.events or []
198
+ for ev in reversed(events):
199
+ if getattr(ev, "author", None) == getattr(agent, "name", "gemcode"):
200
+ content = getattr(ev, "content", None)
201
+ parts = getattr(content, "parts", None) if content is not None else None
202
+ if parts:
203
+ out_parts = []
204
+ for p in parts:
205
+ t = getattr(p, "text", None)
206
+ if t:
207
+ out_parts.append(t)
208
+ if out_parts:
209
+ txt = "".join(out_parts).strip()
210
+ break
211
+ except Exception:
212
+ txt = ""
213
+ if txt:
214
+ from gemcode.veomem_bridge import record_turn_summary
215
+ record_turn_summary(
216
+ self.cfg.project_root,
217
+ session_id=str(callback_context.session.id),
218
+ text=txt,
219
+ )
220
+ except Exception:
221
+ pass
222
+
192
223
  return None
193
224
 
gemcode/repl_commands.py CHANGED
@@ -337,6 +337,7 @@ def slash_help_lines() -> list[str]:
337
337
  " /notes clear Delete all notes",
338
338
  " /notes edit Open notes in $EDITOR",
339
339
  " /create gemskill <name> [description] Create a new GemSkill (SKILL.md scaffold)",
340
+ " Tip: you can also type “I want to make a new skill” and follow the wizard",
340
341
  " /gemskill <name> Load an existing GemSkill into this session (system prompt)",
341
342
  " /gemskill list|clear List skills or unload all session-loaded skills",
342
343
  " /append gemskill <name> <request> Ask the agent to edit that skill file",
gemcode/repl_slash.py CHANGED
@@ -8,8 +8,10 @@ Returns ``None`` when the line is not a slash command; otherwise a
8
8
  from __future__ import annotations
9
9
 
10
10
  import os
11
+ import shlex
11
12
  import uuid
12
13
  from dataclasses import dataclass
14
+ from pathlib import Path
13
15
  from typing import Any, Callable, Iterable
14
16
 
15
17
  from gemcode.config import GemCodeConfig
@@ -98,11 +100,26 @@ async def process_repl_slash(
98
100
  out(" /attach clear Clear the queue")
99
101
  out("Aliases: /file, /image, /img — same queue.")
100
102
  out("Types: Gemini-supported MIME (e.g. images, PDF, audio, video, text). Default max ~20 MiB each.")
103
+ out("Inline prompt: /image <path> <prompt...> (or use `--` before the prompt)")
101
104
  out("CLI: gemcode -C . --attach ./doc.pdf \"Summarize this\"")
102
105
  out()
103
106
  return ReplSlashResult(skip_model_turn=True)
104
- low_i = raw_i.lower()
105
- if low_i == "list":
107
+
108
+ # Parse args as:
109
+ # - list|clear (no further args)
110
+ # - <path> [--] <optional prompt...>
111
+ #
112
+ # We support quoted paths and best-effort unquoted paths with spaces by
113
+ # scanning for the longest prefix that resolves to an existing file.
114
+ try:
115
+ tokens = shlex.split(raw_i, posix=True)
116
+ except ValueError:
117
+ tokens = raw_i.split()
118
+
119
+ if not tokens:
120
+ return ReplSlashResult(skip_model_turn=True)
121
+
122
+ if len(tokens) == 1 and tokens[0].strip().lower() == "list":
106
123
  pend = cfg.pending_attachment_paths
107
124
  if not pend:
108
125
  out("(no attachments queued)")
@@ -112,15 +129,43 @@ async def process_repl_slash(
112
129
  out(f" {i}. {p}")
113
130
  out()
114
131
  return ReplSlashResult(skip_model_turn=True)
115
- if low_i == "clear":
132
+ if len(tokens) == 1 and tokens[0].strip().lower() == "clear":
116
133
  cfg.pending_attachment_paths.clear()
117
134
  out("Attachment queue cleared.")
118
135
  out()
119
136
  return ReplSlashResult(skip_model_turn=True)
120
- resolved = resolve_attachment_path(raw_i, project_root=cfg.project_root)
137
+
138
+ # Find the longest token prefix that resolves to a real file.
139
+ best_i: int | None = None
140
+ best_resolved: Path | None = None
141
+ for i in range(1, len(tokens) + 1):
142
+ cand = " ".join(tokens[:i]).strip()
143
+ if not cand:
144
+ continue
145
+ resolved_try = resolve_attachment_path(cand, project_root=cfg.project_root)
146
+ if resolved_try.is_file():
147
+ best_i = i
148
+ best_resolved = resolved_try
149
+
150
+ # Fallback: treat first token as the path (keeps old behavior).
151
+ if best_i is None or best_resolved is None:
152
+ path_raw = tokens[0]
153
+ resolved = resolve_attachment_path(path_raw, project_root=cfg.project_root)
154
+ remainder_tokens = tokens[1:]
155
+ else:
156
+ path_raw = " ".join(tokens[:best_i]).strip()
157
+ resolved = best_resolved
158
+ remainder_tokens = tokens[best_i:]
159
+
160
+ if remainder_tokens and remainder_tokens[0] == "--":
161
+ remainder_tokens = remainder_tokens[1:]
162
+ trailing_prompt = " ".join(remainder_tokens).strip()
163
+
121
164
  if not resolved.is_file():
122
- out(f"Not a file: {raw_i}")
165
+ out(f"Not a file: {path_raw}")
123
166
  out("(Resolved relative to cwd, then project root.)")
167
+ if trailing_prompt:
168
+ out("Tip: quote paths with spaces, e.g. /image \"./My File.png\" analyze this")
124
169
  out()
125
170
  return ReplSlashResult(skip_model_turn=True)
126
171
  if len(cfg.pending_attachment_paths) >= 16:
@@ -128,6 +173,11 @@ async def process_repl_slash(
128
173
  out()
129
174
  return ReplSlashResult(skip_model_turn=True)
130
175
  cfg.pending_attachment_paths.append(resolved)
176
+ if trailing_prompt:
177
+ out(f"Queued: {resolved} (attaching now)")
178
+ out()
179
+ return ReplSlashResult(model_prompt=trailing_prompt)
180
+
131
181
  out(f"Queued: {resolved}")
132
182
  out(f" ({len(cfg.pending_attachment_paths)} file(s) — send your next message to attach)")
133
183
  out()
@@ -160,14 +210,22 @@ async def process_repl_slash(
160
210
  out("Tip: /skills list")
161
211
  out()
162
212
  return ReplSlashResult(skip_model_turn=True)
163
- expanded = expand_skill_text(s, arguments=sk_args, session_id=session_id)
213
+ # Token-efficient one-shot invocation: do NOT inline the full SKILL.md.
214
+ # Instead, point the model at the skill and require it to load/read it as needed.
164
215
  files = list_supporting_files(s)
165
- prompt = (
166
- f"Apply GemSkill `/{s.meta.name}`.\n\n"
167
- f"## Skill instructions\n{expanded}\n\n"
168
- + (f"## Skill supporting files\n{', '.join(files)}\n\n" if files else "")
169
- + "Now carry out the user's request using the skill instructions."
170
- )
216
+ prompt_parts = [
217
+ f"User invoked GemSkill `/{s.meta.name}`.\n\n",
218
+ f"## Arguments\n{sk_args or '(none)'}\n\n",
219
+ "## Instructions\n",
220
+ "1. Load the skill instructions using the `load_skill` tool (preferred) or by reading the SKILL.md file.\n",
221
+ "2. Only read supporting files if needed (keep it efficient).\n",
222
+ "3. Then carry out the user's request.\n\n",
223
+ f"## Skill file\n{s.skill_md}\n\n",
224
+ ]
225
+ if files:
226
+ prompt_parts.append(f"## Supporting files (optional)\n{', '.join(files)}\n\n")
227
+ prompt_parts.append("Now proceed.")
228
+ prompt = "".join(prompt_parts)
171
229
  return ReplSlashResult(model_prompt=prompt)
172
230
 
173
231
  # ── /gemskill (load full skill into session system prompt) ────────────────
@@ -1114,15 +1172,15 @@ async def process_repl_slash(
1114
1172
  return ReplSlashResult(skip_model_turn=True)
1115
1173
 
1116
1174
  if name == "init":
1117
- gemini_md = cfg.project_root / "GEMINI.md"
1118
- if gemini_md.exists() and (sc.args or "").strip().lower() not in ("force", "overwrite", "-f"):
1119
- out(f"GEMINI.md already exists at {gemini_md}.")
1175
+ gemcode_md = cfg.project_root / "gemcode.md"
1176
+ if gemcode_md.exists() and (sc.args or "").strip().lower() not in ("force", "overwrite", "-f"):
1177
+ out(f"gemcode.md already exists at {gemcode_md}.")
1120
1178
  out("Use /init force to regenerate it, or edit it manually.")
1121
1179
  out()
1122
1180
  return ReplSlashResult(skip_model_turn=True)
1123
- # Dispatch to the model to analyze the project and write GEMINI.md.
1181
+ # Dispatch to the model to analyze the project and write gemcode.md.
1124
1182
  init_prompt = (
1125
- "Analyze this codebase and generate a GEMINI.md file for me.\n\n"
1183
+ "Analyze this codebase and generate a gemcode.md file for me.\n\n"
1126
1184
  "To do this:\n"
1127
1185
  "1. Run `list_directory('.')` to understand the project structure\n"
1128
1186
  "2. Read `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `README.md` "
@@ -1130,9 +1188,9 @@ async def process_repl_slash(
1130
1188
  "3. Look at the source directory structure (src/, lib/, app/, etc.)\n"
1131
1189
  "4. Check for test directories and test runner config\n"
1132
1190
  "5. Look for linting/formatting config files (.eslintrc, .prettierrc, ruff.toml, etc.)\n\n"
1133
- "Write **only** to `GEMINI.md` at the project root. Do **not** create "
1191
+ "Write **only** to `gemcode.md` at the project root. Do **not** create "
1134
1192
  "`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, or similar.\n\n"
1135
- "Then write a GEMINI.md file at the project root containing:\n"
1193
+ "Then write a gemcode.md file at the project root containing:\n"
1136
1194
  "# Project Name\n"
1137
1195
  "One-sentence description.\n\n"
1138
1196
  "## Build & Test\n"
@@ -1150,10 +1208,10 @@ async def process_repl_slash(
1150
1208
  "## Workflow\n"
1151
1209
  "- Any git branching rules from README or CONTRIBUTING\n"
1152
1210
  "- PR/commit conventions\n\n"
1153
- "Keep it under 200 lines. Write the file to GEMINI.md now."
1211
+ "Keep it under 200 lines. Write the file to gemcode.md now."
1154
1212
  )
1155
- out("Analyzing project to generate GEMINI.md…")
1156
- out("(GemCode will read the project structure and write a starting GEMINI.md)")
1213
+ out("Analyzing project to generate gemcode.md…")
1214
+ out("(GemCode will read the project structure and write a starting gemcode.md)")
1157
1215
  out()
1158
1216
  return ReplSlashResult(model_prompt=init_prompt)
1159
1217
 
@@ -368,6 +368,21 @@ def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None) -> Runner
368
368
  except Exception:
369
369
  pass
370
370
 
371
+ # VeoMem wake-up snapshot (optional): load once per session and keep stable.
372
+ try:
373
+ import os
374
+ if os.environ.get("GEMCODE_VEOMEM", "").strip().lower() in ("1", "true", "yes", "on"):
375
+ from veomem.wakeup import build_wake_up_context # type: ignore[import-not-found]
376
+ # Ensure store exists; build_wake_up_context can work even if empty.
377
+ from veomem.store import init_store # type: ignore[import-not-found]
378
+
379
+ init_store(cfg.project_root)
380
+ snap2 = build_wake_up_context(cfg.project_root, max_chars=5000, limit=30)
381
+ txt = (snap2.get("text") if isinstance(snap2, dict) else "") or ""
382
+ object.__setattr__(cfg, "_veomem_wakeup_text", txt.strip() if isinstance(txt, str) else "")
383
+ except Exception:
384
+ pass
385
+
371
386
  # ── MCP toolsets from .gemcode/mcp.json ─────────────────────────────────
372
387
  # Supports stdio, http (Streamable HTTP), and sse connection types.
373
388
  try:
@@ -389,6 +404,46 @@ def create_runner(cfg: GemCodeConfig, extra_tools: list | None = None) -> Runner
389
404
  except Exception:
390
405
  pass # OpenAPIToolset not in this ADK version — continue without
391
406
 
407
+ # ── AFC compatibility prompt (optional) ───────────────────────────────────
408
+ # Gemini Automatic Function Calling (AFC) only works when the tool list is
409
+ # composed of Python callables. Some ADK toolsets (MCP/OpenAPI/built-in
410
+ # declarations) may be non-callable and cause AFC to be disabled.
411
+ #
412
+ # If we're in an interactive terminal and the user opted into prompting,
413
+ # ask whether to keep "all tools" (AFC disabled) or restrict to callables
414
+ # (AFC enabled).
415
+ try:
416
+ import os
417
+ import sys
418
+
419
+ prompt_afc = os.environ.get("GEMCODE_AFC_PROMPT", "1").strip().lower() in ("1", "true", "yes", "on")
420
+ if prompt_afc and hasattr(sys.stdin, "isatty") and sys.stdin.isatty():
421
+ tools_list = list(merged_extra_tools or [])
422
+ noncallable = [t for t in tools_list if not callable(t)]
423
+ if noncallable and getattr(cfg, "_afc_choice", None) not in ("all", "callables"):
424
+ print(
425
+ "\n[gemcode] AFC compatibility\n"
426
+ "Gemini Automatic Function Calling (AFC) works best with Python callables only.\n"
427
+ "Some enabled toolsets appear non-callable, which can disable AFC.\n\n"
428
+ "Choose tool mode for this session:\n"
429
+ " [Enter] all tools (recommended; AFC may be disabled)\n"
430
+ " c callable-only tools (keeps AFC; disables MCP/OpenAPI/toolsets)\n",
431
+ file=sys.stderr,
432
+ )
433
+ try:
434
+ ans = input("afc> ").strip().lower()
435
+ except EOFError:
436
+ ans = ""
437
+ if ans.startswith("c"):
438
+ object.__setattr__(cfg, "_afc_choice", "callables")
439
+ else:
440
+ object.__setattr__(cfg, "_afc_choice", "all")
441
+
442
+ if getattr(cfg, "_afc_choice", None) == "callables":
443
+ merged_extra_tools = [t for t in tools_list if callable(t)] or None
444
+ except Exception:
445
+ pass
446
+
392
447
  # Computer-use: ADK ComputerUseToolset backed by our Playwright BrowserComputer.
393
448
  # Probe Playwright BEFORE building the agent so model routing (which runs
394
449
  # inside build_root_agent → pick_effective_model) never switches to
gemcode/skills.py CHANGED
@@ -135,11 +135,16 @@ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
135
135
 
136
136
  Supported:
137
137
  - key: value (single-line)
138
+ - key: >
139
+ multi-line
140
+ (folded; newlines become spaces)
141
+ - key: |
142
+ multi-line
143
+ (literal; newlines preserved)
138
144
  - key: [a, b] (parsed as a string; caller can split if needed)
139
145
 
140
146
  Not supported:
141
147
  - nested objects
142
- - multi-line scalars
143
148
  - full YAML spec
144
149
  """
145
150
  m = _FRONTMATTER_RE.match(text or "")
@@ -148,18 +153,58 @@ def _parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
148
153
  raw = m.group(1)
149
154
  body = (text or "")[m.end() :]
150
155
  d: dict[str, str] = {}
151
- for line in raw.splitlines():
156
+ lines = raw.splitlines()
157
+ i = 0
158
+ while i < len(lines):
159
+ line = lines[i]
152
160
  if not line.strip() or line.strip().startswith("#"):
161
+ i += 1
153
162
  continue
154
163
  if ":" not in line:
164
+ i += 1
155
165
  continue
156
166
  k, v = line.split(":", 1)
157
167
  k = k.strip()
158
- v = v.strip()
168
+ v = v.rstrip()
169
+ v_stripped = v.strip()
170
+
171
+ # Multi-line scalar blocks: `key: >` or `key: |` followed by indented lines.
172
+ if v_stripped in (">", "|"):
173
+ style = v_stripped
174
+ block: list[str] = []
175
+ i += 1
176
+ while i < len(lines):
177
+ nxt = lines[i]
178
+ # YAML block scalars require indentation. We accept 2+ spaces or a tab.
179
+ if not (nxt.startswith(" ") or nxt.startswith("\t")):
180
+ break
181
+ block.append(nxt.lstrip(" \t"))
182
+ i += 1
183
+ if style == ">":
184
+ # folded: join non-empty lines with spaces; keep paragraph breaks as newline.
185
+ paras: list[str] = []
186
+ cur: list[str] = []
187
+ for ln in block:
188
+ if not ln.strip():
189
+ if cur:
190
+ paras.append(" ".join(x.strip() for x in cur if x.strip()))
191
+ cur = []
192
+ continue
193
+ cur.append(ln)
194
+ if cur:
195
+ paras.append(" ".join(x.strip() for x in cur if x.strip()))
196
+ d[k.lower()] = "\n".join(paras).strip()
197
+ else:
198
+ d[k.lower()] = "\n".join(block).rstrip()
199
+ continue
200
+
201
+ # Single-line scalar
202
+ v2 = v_stripped
159
203
  # Strip simple surrounding quotes
160
- if len(v) >= 2 and ((v[0] == v[-1] == '"') or (v[0] == v[-1] == "'")):
161
- v = v[1:-1]
162
- d[k.lower()] = v
204
+ if len(v2) >= 2 and ((v2[0] == v2[-1] == '"') or (v2[0] == v2[-1] == "'")):
205
+ v2 = v2[1:-1]
206
+ d[k.lower()] = v2
207
+ i += 1
163
208
  return d, body
164
209
 
165
210