pcode 0.1.6__tar.gz → 0.1.7__tar.gz
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.
- {pcode-0.1.6 → pcode-0.1.7}/PKG-INFO +1 -1
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/app.py +30 -8
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/appearance.py +10 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/Brief format.md +2 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/__init__.py +95 -46
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/plan_mode.py +129 -25
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/file_refs.py +1 -4
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/prompt_keys.py +5 -25
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/ui.py +1 -5
- pcode-0.1.6/src/pcode/paste.py +0 -45
- {pcode-0.1.6 → pcode-0.1.7}/.gitignore +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/LICENSE +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/README.md +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/pyproject.toml +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/__init__.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/_job_supervisor.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/agent.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/anthropic_oauth.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/aside.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/aside_guard.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/aside_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/auth.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/background_delegation.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/block.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/browser.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cache_diagnostics.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cache_settings.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cache_warnings.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/__init__.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/cli.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/errors.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/messages.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/model.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/resume.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/session.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/session_pool.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/web.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/claude_sdk/workspace.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/cli.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/clipboard.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/code_mode.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/codex_login.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/command_transcript.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/commands.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/compaction.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/completion.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/config.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/config_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/context_breakdown.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/context_usage.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/controller.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/conversation_ids.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/conversation_tree.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/copy_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/delegation.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/delta.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/diagnostics.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edit_preview.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edit_transcript.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edit_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/edits.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/__init__.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/cli.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/listener.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/mailbox.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/outbound.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/parsing.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/render.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/email_remote/state.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/error_report.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/ext.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extension_guide.md +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/Briefs.base +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/Home.md +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/basic_memory/memory-states.css +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/browser.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/ponytail.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/sandbox.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/session_history.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/extensions/web_research.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/filesystem.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/frame.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/gateway.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/git_diff.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/history.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/history_cursor.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/history_embeddings.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/host.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/host_protocol.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/host_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/input_keys.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/inspection.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/inspector_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/isolated_delegation.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/job_notices.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/jobs.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/jobs_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/keyboard_protocol.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/keymap.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/layout_speed.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/links.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/links_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/live.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/llm_proxy.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/logins.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/mcp.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/mcp_notice.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/mcp_oauth.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian_process.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian_reminders.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/meridian_setup.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/model_metadata.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/model_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/models.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/native_results.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/oauth_pages.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/output_limits.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/path_completion.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/plan_preview.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/plan_sizes.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/planning.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/popup_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/preferences.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/prefix_keys.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/proctitle.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/profile_benchmark.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/profiling.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/project_trust.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/remote.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/remote_print.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/remote_profile.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/replay_benchmark.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/repo_context.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/retries.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/rpc.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/runtime.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/sandbox.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/session_naming.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/session_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/sessions.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/shell.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/shell_mode.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/shell_tools.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/skills.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/stall_log.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/startup_input.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/steering.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/stream_display.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/stream_events.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/strict_tools.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/syntax.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/syntax_colors.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/task_prompt.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/task_worktrees.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/terminal_notify.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/terminal_text.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/text_selection.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/theme.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/theme_gallery.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/thinking_markdown.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/token_accounting.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tool_display.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tool_output_limits.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tool_panel.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/transcript_log.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/transcript_notice.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/tree_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/turn.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/update_check.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/usage.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/word_wrap.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/worker_ui.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/workers.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/workspace.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/workspace_filesystem.py +0 -0
- {pcode-0.1.6 → pcode-0.1.7}/src/pcode/worktree.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Terminal-native AI coding agent with background jobs, parallel worktrees, forkable sessions, and multi-model support
|
|
5
5
|
Project-URL: Homepage, https://github.com/cruxwell/pcode
|
|
6
6
|
Project-URL: Documentation, https://cruxwell.github.io/pcode/
|
|
@@ -1227,14 +1227,24 @@ class PreviewApp:
|
|
|
1227
1227
|
self.transcript.syntax_gallery()
|
|
1228
1228
|
|
|
1229
1229
|
def theme(self, argument: str) -> None:
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
if
|
|
1236
|
-
|
|
1237
|
-
|
|
1230
|
+
"""Pick and save a palette; bare `/theme` flips it for this session only.
|
|
1231
|
+
|
|
1232
|
+
Saving the flip would pin every later session to it, and with it turn
|
|
1233
|
+
off following the terminal's light/dark switches.
|
|
1234
|
+
"""
|
|
1235
|
+
theme = argument or ("light" if self.transcript.resolved_theme == "dark" else "dark")
|
|
1236
|
+
self.transcript.theme = theme
|
|
1237
|
+
if not argument:
|
|
1238
|
+
message = f"{theme} for this session; /theme {theme} saves it"
|
|
1239
|
+
else:
|
|
1240
|
+
self.persist_defaults(theme=theme)
|
|
1241
|
+
message = theme
|
|
1242
|
+
if theme == "auto":
|
|
1243
|
+
message += f" ({self.transcript.resolved_theme})"
|
|
1244
|
+
if self._appearance is not None:
|
|
1245
|
+
# The detected value may be stale; the answer repaints if it differs.
|
|
1246
|
+
self._appearance.refresh()
|
|
1247
|
+
self.transcript.flash(f"Theme: {message}.")
|
|
1238
1248
|
self.transcript.regenerate()
|
|
1239
1249
|
|
|
1240
1250
|
def follow_appearance(self, detected: str) -> None:
|
|
@@ -2709,6 +2719,9 @@ def main() -> None:
|
|
|
2709
2719
|
"hosts",
|
|
2710
2720
|
)
|
|
2711
2721
|
parser.add_argument("--hosts", action="store_true", help="List running session hosts and exit")
|
|
2722
|
+
parser.add_argument(
|
|
2723
|
+
"--json", action="store_true", help="With --hosts: one JSON array, for scripts and plugins"
|
|
2724
|
+
)
|
|
2712
2725
|
host_kinds = ("all", "stale", "idle")
|
|
2713
2726
|
parser.add_argument(
|
|
2714
2727
|
"--kill-hosts",
|
|
@@ -3179,6 +3192,8 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
|
|
3179
3192
|
parser.error("--fork cannot be combined with --attach")
|
|
3180
3193
|
if args.compact and not args.sessions:
|
|
3181
3194
|
parser.error("--compact applies to --sessions")
|
|
3195
|
+
if args.json and not args.hosts:
|
|
3196
|
+
parser.error("--json applies to --hosts")
|
|
3182
3197
|
if args.print:
|
|
3183
3198
|
if args.theme_preview or args.sessions:
|
|
3184
3199
|
parser.error("--print cannot be combined with --theme-preview or --sessions")
|
|
@@ -3225,6 +3240,13 @@ def _run_cli(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None:
|
|
|
3225
3240
|
if args.kill_hosts:
|
|
3226
3241
|
_kill_hosts(args.kill_hosts, entries, code)
|
|
3227
3242
|
return
|
|
3243
|
+
if args.json:
|
|
3244
|
+
import json
|
|
3245
|
+
|
|
3246
|
+
# A contract for callers such as the Obsidian bridge: add keys, never rename.
|
|
3247
|
+
keys = ("id", "session_id", "state", "workspace", "title", "last_prompt", "attached")
|
|
3248
|
+
print(json.dumps([{key: getattr(entry, key) for key in keys} for entry in entries]))
|
|
3249
|
+
return
|
|
3228
3250
|
for entry in entries:
|
|
3229
3251
|
print(f"{entry.id} {host_row(entry, None, code=code).strip()}")
|
|
3230
3252
|
if not entries:
|
|
@@ -144,6 +144,13 @@ class AppearanceWatch:
|
|
|
144
144
|
self.suspended = False
|
|
145
145
|
if enabled:
|
|
146
146
|
self.enabled = _write_all(self.fd, ENABLE_REPORTS)
|
|
147
|
+
# A switch while the other program had the terminal went unseen.
|
|
148
|
+
self.refresh()
|
|
149
|
+
|
|
150
|
+
def refresh(self) -> None:
|
|
151
|
+
"""Ask for the background now; the answer arrives as input like any other."""
|
|
152
|
+
if self.enabled and not self.suspended:
|
|
153
|
+
_write_all(self.fd, QUERY_BACKGROUND)
|
|
147
154
|
|
|
148
155
|
async def query(self) -> None:
|
|
149
156
|
for delay in QUERY_DELAYS:
|
|
@@ -155,6 +162,9 @@ class AppearanceWatch:
|
|
|
155
162
|
if self.fd is None or not _isatty(self.fd) or os.environ.get("TERM") == "dumb":
|
|
156
163
|
return
|
|
157
164
|
self.enabled = _write_all(self.fd, ENABLE_REPORTS)
|
|
165
|
+
# The startup probe is skipped when input is already waiting, leaving a
|
|
166
|
+
# guess from COLORFGBG that only a later switch would otherwise correct.
|
|
167
|
+
self.refresh()
|
|
158
168
|
reader = self.reader if self.reader is not None else desktop_reader()
|
|
159
169
|
pending = None
|
|
160
170
|
try:
|
|
@@ -52,6 +52,8 @@ A **brief** explains one project as it is now: what it is, where it's at, how it
|
|
|
52
52
|
| `plans` | links to open plans | | |
|
|
53
53
|
| `sources` | session IDs, plus code paths or commit ranges | same | |
|
|
54
54
|
|
|
55
|
+
Properties are YAML, and one bad value breaks the whole block: Obsidian then shows it as raw red text instead of properties. Wrap every free-text value (`scope`, `tldr`, `next` and each `sources` item) in single quotes, doubling any `'` inside: `tldr: 'Checks the org''s code: once per ticket'`. Unquoted, a `: ` anywhere or a leading `` ` ``, `#`, `*`, `[`, `{`, `&`, `!`, `|`, `>`, `%` or `@` breaks it.
|
|
56
|
+
|
|
55
57
|
## Sections
|
|
56
58
|
|
|
57
59
|
| Section | Answers | Budget |
|
|
@@ -6,7 +6,7 @@ config directory, which is never in a repository, so org names stay local.
|
|
|
6
6
|
|
|
7
7
|
A **brief** explains a project as it is: what it is, where it's at, how it
|
|
8
8
|
works, what's wrong, what's next. A **plan** is a proposed brief in the same
|
|
9
|
-
format;
|
|
9
|
+
format; `/plan --obsidian` writes it into the vault. How both
|
|
10
10
|
are written lives in the vault's `Brief format.md`, which the model reads
|
|
11
11
|
before writing, so the user can change the format without touching code.
|
|
12
12
|
|
|
@@ -23,9 +23,7 @@ tools. What this module adds is only:
|
|
|
23
23
|
Obsidian reach the model without being retyped.
|
|
24
24
|
|
|
25
25
|
Known limits: an edit the user makes mid-turn to a note the model then edits
|
|
26
|
-
again is folded into the model's edit and not reported
|
|
27
|
-
trusts the extensions_on preference, so a user file that overrides this
|
|
28
|
-
extension still gets vault plans from this module.
|
|
26
|
+
again is folded into the model's edit and not reported.
|
|
29
27
|
"""
|
|
30
28
|
|
|
31
29
|
import asyncio
|
|
@@ -34,6 +32,7 @@ import json
|
|
|
34
32
|
import re
|
|
35
33
|
import shlex
|
|
36
34
|
import shutil
|
|
35
|
+
import tempfile
|
|
37
36
|
import tomllib
|
|
38
37
|
from dataclasses import dataclass
|
|
39
38
|
from pathlib import Path
|
|
@@ -120,11 +119,22 @@ def load_orgs(path: Path | None = None) -> list[Org]:
|
|
|
120
119
|
except (OSError, tomllib.TOMLDecodeError) as error:
|
|
121
120
|
raise ValueError(f"{path}: {error}") from None
|
|
122
121
|
default_sync = bool(data.get("auto_sync", False))
|
|
123
|
-
|
|
122
|
+
root_value = data.get("vault_root", DEFAULT_VAULT_ROOT)
|
|
123
|
+
if not isinstance(root_value, str) or not root_value.strip():
|
|
124
|
+
raise ValueError(f"{path}: vault_root must be a nonempty path string")
|
|
125
|
+
vault_root = Path(root_value).expanduser()
|
|
126
|
+
entries = data.get("org", [])
|
|
127
|
+
if not isinstance(entries, list):
|
|
128
|
+
raise ValueError(f"{path}: org must be an array of [[org]] tables")
|
|
124
129
|
orgs = []
|
|
125
|
-
for entry in
|
|
126
|
-
if not isinstance(entry, dict) or
|
|
130
|
+
for entry in entries:
|
|
131
|
+
if not isinstance(entry, dict) or any(
|
|
132
|
+
not isinstance(entry.get(key), str) or not entry[key].strip()
|
|
133
|
+
for key in ("name", "root")
|
|
134
|
+
):
|
|
127
135
|
raise ValueError(f"{path}: every [[org]] needs a name and a root")
|
|
136
|
+
if "vault" in entry and (not isinstance(entry["vault"], str) or not entry["vault"].strip()):
|
|
137
|
+
raise ValueError(f"{path}: vault must be a nonempty path string")
|
|
128
138
|
name = str(entry["name"])
|
|
129
139
|
if safe_name(name) != name:
|
|
130
140
|
raise ValueError(f"{path}: org name {name!r} must be a plain file name")
|
|
@@ -235,6 +245,43 @@ def edit_diff(path: Path, before: str, after: str) -> str:
|
|
|
235
245
|
return "\n".join(lines)
|
|
236
246
|
|
|
237
247
|
|
|
248
|
+
def is_side(ctx) -> bool:
|
|
249
|
+
"""Side questions and shared workers must not consume the main conversation's edits."""
|
|
250
|
+
from pcode.aside_guard import AsideGuard
|
|
251
|
+
|
|
252
|
+
if getattr(getattr(ctx, "agent", None), "name", None) == "worker":
|
|
253
|
+
return True
|
|
254
|
+
capabilities = getattr(ctx, "capabilities", None) or {}
|
|
255
|
+
return any(isinstance(c, AsideGuard) for c in capabilities.values())
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class NoteFeedback:
|
|
259
|
+
"""Snapshots shared by broad memory feedback and active-plan-only feedback."""
|
|
260
|
+
|
|
261
|
+
def __init__(self):
|
|
262
|
+
self.seen: dict[Path, str] = {}
|
|
263
|
+
|
|
264
|
+
def observe(self, path: Path) -> None:
|
|
265
|
+
try:
|
|
266
|
+
self.seen[path] = path.read_text(encoding="utf-8")
|
|
267
|
+
except OSError:
|
|
268
|
+
self.seen.pop(path, None)
|
|
269
|
+
|
|
270
|
+
def edits(self, only: Path | None = None) -> list[str]:
|
|
271
|
+
changed = []
|
|
272
|
+
for path, before in list(self.seen.items()):
|
|
273
|
+
if only is not None and path != only:
|
|
274
|
+
continue
|
|
275
|
+
try:
|
|
276
|
+
after = path.read_text(encoding="utf-8")
|
|
277
|
+
except OSError:
|
|
278
|
+
continue
|
|
279
|
+
if after != before:
|
|
280
|
+
self.seen[path] = after
|
|
281
|
+
changed.append(f"`{path}`:\n```diff\n{edit_diff(path, before, after)}\n```")
|
|
282
|
+
return changed
|
|
283
|
+
|
|
284
|
+
|
|
238
285
|
# --- Vault setup ---------------------------------------------------------------------
|
|
239
286
|
|
|
240
287
|
|
|
@@ -302,24 +349,49 @@ def enabled() -> bool:
|
|
|
302
349
|
return NAME in name_list("extensions_on") - name_list("extensions_off")
|
|
303
350
|
|
|
304
351
|
|
|
305
|
-
def
|
|
306
|
-
"""
|
|
307
|
-
|
|
308
|
-
|
|
352
|
+
def plan_guidance(directory: Path) -> str:
|
|
353
|
+
"""Guidance also works for a saved absolute marker without current org configuration."""
|
|
354
|
+
return (
|
|
355
|
+
f"- This plan is a proposed brief: re-read `{directory.parent / FORMAT_NOTE}` before "
|
|
356
|
+
"creating or revising it and follow its Plans section (type: plan, state: proposed, "
|
|
357
|
+
"cssclasses: [proposed], the PROPOSED banner). Link an existing project brief via "
|
|
358
|
+
f"`project:` if one in `{directory}` matches this workspace's `dirs`. "
|
|
359
|
+
"If none exists, write a standalone plan; do not create a project brief."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def plan_location(workspace: Path) -> PlanLocation:
|
|
364
|
+
"""The vault for `/plan --obsidian`, whether or not this extension is on.
|
|
365
|
+
|
|
366
|
+
Raises ValueError with setup guidance when no writable org vault covers `workspace`.
|
|
367
|
+
"""
|
|
309
368
|
try:
|
|
310
369
|
place = place_for(workspace)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
370
|
+
if place is None:
|
|
371
|
+
raise ValueError(f"No configured org covers {workspace}")
|
|
372
|
+
vault = place.org.vault
|
|
373
|
+
# Planning needs neither the dashboard nor Obsidian settings or sync.
|
|
374
|
+
format_note = vault / FORMAT_NOTE
|
|
375
|
+
if (format_note.exists() or format_note.is_symlink()) and not format_note.is_file():
|
|
376
|
+
raise ValueError(f"{format_note} must be a file")
|
|
377
|
+
briefs = vault / BRIEFS
|
|
378
|
+
briefs.mkdir(parents=True, exist_ok=True)
|
|
379
|
+
# Existing directories and format notes don't prove new plans can be written.
|
|
380
|
+
with tempfile.NamedTemporaryFile(dir=briefs, prefix=".pcode-plan-") as probe:
|
|
381
|
+
probe.write(b"plan write probe\n")
|
|
382
|
+
probe.flush()
|
|
383
|
+
if not format_note.exists():
|
|
384
|
+
write_text(format_note, (FILES / FORMAT_NOTE).read_text(encoding="utf-8"))
|
|
385
|
+
except (OSError, ValueError) as error:
|
|
386
|
+
raise ValueError(
|
|
387
|
+
f"/plan --obsidian: {error}. Configure a matching [[org]] with name, root "
|
|
388
|
+
f"and a writable vault in {config_path()}, then retry (or use /plan --local)."
|
|
389
|
+
) from None
|
|
316
390
|
project = place.repo or (Path(place.dir).name if place.dir != "." else place.org.name)
|
|
317
391
|
return PlanLocation(
|
|
318
392
|
vault / BRIEFS,
|
|
319
393
|
safe_name(project, 40) or place.org.name,
|
|
320
|
-
|
|
321
|
-
"Plans section (type: plan, state: proposed, cssclasses: [proposed], the PROPOSED "
|
|
322
|
-
"banner, `project:` linking the brief it changes, and only the sections that change).",
|
|
394
|
+
plan_guidance(vault / BRIEFS),
|
|
323
395
|
)
|
|
324
396
|
|
|
325
397
|
|
|
@@ -410,7 +482,7 @@ def setup(pcode) -> None:
|
|
|
410
482
|
current: dict = {"session": None}
|
|
411
483
|
# Sync per session; None holds a choice made before the first message.
|
|
412
484
|
sync: dict[str | None, bool] = {}
|
|
413
|
-
|
|
485
|
+
feedback = NoteFeedback()
|
|
414
486
|
|
|
415
487
|
def session_of(ctx) -> str | None:
|
|
416
488
|
return base_conversation(getattr(ctx, "conversation_id", None)) or current["session"]
|
|
@@ -433,14 +505,6 @@ def setup(pcode) -> None:
|
|
|
433
505
|
if session and state_path(session).parent.is_dir(): # Unsaved sessions have none.
|
|
434
506
|
write_text(state_path(session), json.dumps({"sync": on}))
|
|
435
507
|
|
|
436
|
-
def is_side(ctx) -> bool:
|
|
437
|
-
from pcode.aside_guard import AsideGuard
|
|
438
|
-
|
|
439
|
-
if getattr(getattr(ctx, "agent", None), "name", None) == "worker":
|
|
440
|
-
return True
|
|
441
|
-
capabilities = getattr(ctx, "capabilities", None) or {}
|
|
442
|
-
return any(isinstance(c, AsideGuard) for c in capabilities.values())
|
|
443
|
-
|
|
444
508
|
def in_vault(path: Path) -> bool:
|
|
445
509
|
return path.suffix == ".md" and (path == vault or vault in path.parents)
|
|
446
510
|
|
|
@@ -450,7 +514,7 @@ def setup(pcode) -> None:
|
|
|
450
514
|
if is_side(ctx) or not session or session == current["session"]:
|
|
451
515
|
return
|
|
452
516
|
current["session"] = session
|
|
453
|
-
seen.clear() #
|
|
517
|
+
feedback.seen.clear() # Another conversation's notes are not this one's to diff.
|
|
454
518
|
if None in sync:
|
|
455
519
|
set_sync(sync.pop(None), session)
|
|
456
520
|
|
|
@@ -460,24 +524,9 @@ def setup(pcode) -> None:
|
|
|
460
524
|
path = Path(str(args.get("path", ""))).expanduser()
|
|
461
525
|
path = (path if path.is_absolute() else pcode.workspace / path).resolve()
|
|
462
526
|
if in_vault(path):
|
|
463
|
-
|
|
464
|
-
seen[path] = await asyncio.to_thread(path.read_text, encoding="utf-8")
|
|
465
|
-
except OSError:
|
|
466
|
-
seen.pop(path, None)
|
|
527
|
+
await asyncio.to_thread(feedback.observe, path)
|
|
467
528
|
return result
|
|
468
529
|
|
|
469
|
-
def edits() -> list[str]:
|
|
470
|
-
changed = []
|
|
471
|
-
for path, before in list(seen.items()):
|
|
472
|
-
try:
|
|
473
|
-
after = path.read_text(encoding="utf-8")
|
|
474
|
-
except OSError:
|
|
475
|
-
continue
|
|
476
|
-
if after != before:
|
|
477
|
-
seen[path] = after
|
|
478
|
-
changed.append(f"`{path}`:\n```diff\n{edit_diff(path, before, after)}\n```")
|
|
479
|
-
return changed
|
|
480
|
-
|
|
481
530
|
@pcode.hooks.on.before_model_request
|
|
482
531
|
async def remind(ctx, request_context):
|
|
483
532
|
# Once per user message, persisted in history so the cached prefix holds.
|
|
@@ -494,7 +543,7 @@ def setup(pcode) -> None:
|
|
|
494
543
|
):
|
|
495
544
|
return request_context
|
|
496
545
|
lines = [SYNC] if sync_on(session_of(ctx)) else []
|
|
497
|
-
changed = await asyncio.to_thread(edits)
|
|
546
|
+
changed = await asyncio.to_thread(feedback.edits)
|
|
498
547
|
if changed:
|
|
499
548
|
lines += [EDITED, *changed]
|
|
500
549
|
if lines:
|
|
@@ -13,18 +13,22 @@ The model is trusted rather than fenced in: nothing blocks edits, the reminder
|
|
|
13
13
|
just says what the user wants. The active plan lives in a marker file beside
|
|
14
14
|
the plans, so it survives `/reload` (which re-imports this module) and restarts.
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
`--obsidian` makes the plan a note in the workspace's org vault instead
|
|
17
|
+
(`briefs/Plan — <project> — <title>.md`, configured in `memory.toml`), whether or
|
|
18
|
+
not the `basic_memory` extension is on: where a plan lives is a per-plan choice,
|
|
19
|
+
not a memory setting. `--local` (the default) keeps it in `.pcode/plans`. The
|
|
20
|
+
marker holds a vault note's absolute path rather than a local slug. With memory
|
|
21
|
+
off, only the active note gets edit feedback and lifecycle updates.
|
|
20
22
|
"""
|
|
21
23
|
|
|
24
|
+
import asyncio
|
|
22
25
|
import re
|
|
23
26
|
from pathlib import Path
|
|
24
27
|
|
|
25
28
|
from pydantic_ai import ModelRetry
|
|
26
29
|
from pydantic_ai.messages import ModelRequest, ToolReturnPart, UserPromptPart
|
|
27
30
|
|
|
31
|
+
from pcode.conversation_ids import base_conversation
|
|
28
32
|
from pcode.steering import run_request
|
|
29
33
|
|
|
30
34
|
PLANS = ".pcode/plans"
|
|
@@ -49,11 +53,6 @@ Plan mode is on. The user wants to agree on a plan before any implementation.
|
|
|
49
53
|
</plan-mode>"""
|
|
50
54
|
|
|
51
55
|
|
|
52
|
-
def is_worker(ctx) -> bool:
|
|
53
|
-
"""A shared delegate runs this extension's hooks too; plan mode is the parent's."""
|
|
54
|
-
return getattr(ctx.agent, "name", None) == "worker"
|
|
55
|
-
|
|
56
|
-
|
|
57
56
|
def slugify(text: str) -> str:
|
|
58
57
|
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
59
58
|
if len(slug) > 60: # Cut at a word boundary when there is one.
|
|
@@ -67,14 +66,39 @@ def topic_slug(request: str) -> str:
|
|
|
67
66
|
return slugify(" ".join(request.split("\n", 1)[0].split()[:8]))
|
|
68
67
|
|
|
69
68
|
|
|
69
|
+
def parse_destination(argument: str) -> tuple[str | None, str]:
|
|
70
|
+
"""Consume only leading flags, leaving the request's internal whitespace untouched."""
|
|
71
|
+
destination = None
|
|
72
|
+
argument = argument.strip()
|
|
73
|
+
while argument.startswith("-"):
|
|
74
|
+
flag, *rest = argument.split(maxsplit=1)
|
|
75
|
+
if flag not in ("--obsidian", "--local"):
|
|
76
|
+
raise ValueError(f"Unknown /plan flag {flag!r}; use --obsidian or --local.")
|
|
77
|
+
if destination is not None and destination != flag:
|
|
78
|
+
raise ValueError("/plan --obsidian and --local cannot be combined.")
|
|
79
|
+
destination = flag
|
|
80
|
+
argument = rest[0] if rest else ""
|
|
81
|
+
return destination, argument
|
|
82
|
+
|
|
83
|
+
|
|
70
84
|
def setup(pcode) -> None:
|
|
71
85
|
if pcode.is_worker:
|
|
72
86
|
return
|
|
73
87
|
plans = pcode.workspace / PLANS
|
|
74
88
|
marker = plans / ACTIVE
|
|
75
|
-
from pcode.extensions.basic_memory import
|
|
89
|
+
from pcode.extensions.basic_memory import (
|
|
90
|
+
EDITED,
|
|
91
|
+
NOTE_TOOLS,
|
|
92
|
+
NoteFeedback,
|
|
93
|
+
enabled,
|
|
94
|
+
is_side,
|
|
95
|
+
plan_guidance,
|
|
96
|
+
plan_location,
|
|
97
|
+
)
|
|
76
98
|
|
|
77
|
-
|
|
99
|
+
memory_on = enabled()
|
|
100
|
+
feedback = NoteFeedback()
|
|
101
|
+
session = None
|
|
78
102
|
|
|
79
103
|
def active() -> str | None:
|
|
80
104
|
try:
|
|
@@ -98,15 +122,13 @@ def setup(pcode) -> None:
|
|
|
98
122
|
def latest() -> str | None:
|
|
99
123
|
# Only local plans: a vault holds every repo's plans, so "the newest" could
|
|
100
124
|
# be another project's.
|
|
101
|
-
if location:
|
|
102
|
-
return None
|
|
103
125
|
files = (
|
|
104
126
|
sorted(plans.glob("*.md"), key=lambda p: p.stat().st_mtime) if plans.is_dir() else []
|
|
105
127
|
)
|
|
106
128
|
return files[-1].stem if files else None
|
|
107
129
|
|
|
108
|
-
def new_key(request: str) -> str:
|
|
109
|
-
return str(
|
|
130
|
+
def new_key(request: str, destination) -> str:
|
|
131
|
+
return str(destination.path_for(request)) if destination else topic_slug(request)
|
|
110
132
|
|
|
111
133
|
def start(slug: str) -> None:
|
|
112
134
|
plans.mkdir(parents=True, exist_ok=True)
|
|
@@ -116,22 +138,42 @@ def setup(pcode) -> None:
|
|
|
116
138
|
marker.write_text(slug + "\n")
|
|
117
139
|
|
|
118
140
|
def plan_command(argument: str) -> str | None:
|
|
119
|
-
argument = argument
|
|
141
|
+
flag, argument = parse_destination(argument)
|
|
120
142
|
current = active()
|
|
121
|
-
if argument == "off":
|
|
143
|
+
if flag is None and argument == "off":
|
|
122
144
|
if current is None:
|
|
123
145
|
pcode.ui.notify("Plan mode is already off.")
|
|
124
146
|
return
|
|
125
147
|
marker.unlink(missing_ok=True)
|
|
126
148
|
pcode.ui.notify(f"Plan mode off. The plan stays in {relative(current)}.")
|
|
127
149
|
return
|
|
128
|
-
if argument == "status":
|
|
150
|
+
if flag is None and argument == "status":
|
|
129
151
|
pcode.ui.notify(
|
|
130
152
|
f"Plan mode on: {relative(current)}" if current else "Plan mode is off."
|
|
131
153
|
)
|
|
132
154
|
return
|
|
133
|
-
|
|
155
|
+
destination = plan_location(pcode.workspace) if flag == "--obsidian" else None
|
|
156
|
+
compatible = current
|
|
157
|
+
if flag and current:
|
|
158
|
+
matches = (
|
|
159
|
+
Path(current).parent == destination.directory
|
|
160
|
+
if destination
|
|
161
|
+
else not is_vault(current)
|
|
162
|
+
)
|
|
163
|
+
if not matches:
|
|
164
|
+
compatible = None
|
|
165
|
+
slug = (
|
|
166
|
+
new_key(argument, destination)
|
|
167
|
+
if argument
|
|
168
|
+
else (
|
|
169
|
+
compatible or (latest() if flag is None else None) or new_key("plan", destination)
|
|
170
|
+
)
|
|
171
|
+
)
|
|
134
172
|
start(slug)
|
|
173
|
+
if not memory_on and is_vault(slug):
|
|
174
|
+
path = path_of(slug).resolve()
|
|
175
|
+
if slug != current or path not in feedback.seen:
|
|
176
|
+
feedback.observe(path)
|
|
135
177
|
exists = path_of(slug).exists()
|
|
136
178
|
verb = "Resuming" if exists else "Planning in"
|
|
137
179
|
ask = "Say" if argument else "Describe the change; say"
|
|
@@ -147,9 +189,11 @@ def setup(pcode) -> None:
|
|
|
147
189
|
"/plan",
|
|
148
190
|
"Plan before implementing; the model exits when you approve",
|
|
149
191
|
plan_command,
|
|
150
|
-
arguments=("off", "status"),
|
|
192
|
+
arguments=("off", "status", "--obsidian", "--local"),
|
|
151
193
|
free_arguments=True, # anything else is the request to plan
|
|
152
194
|
argument_descriptions={
|
|
195
|
+
"--obsidian": "Keep the plan in the org's Obsidian vault",
|
|
196
|
+
"--local": "Keep the plan in .pcode/plans (the default)",
|
|
153
197
|
"off": "Leave plan mode (the plan file is kept)",
|
|
154
198
|
"status": "Show whether plan mode is on and its file",
|
|
155
199
|
},
|
|
@@ -174,24 +218,68 @@ def setup(pcode) -> None:
|
|
|
174
218
|
if is_vault(current):
|
|
175
219
|
result += (
|
|
176
220
|
" First mark it approved, as the brief format says: `state: approved`, "
|
|
177
|
-
"`cssclasses: [approved]`, the APPROVED banner
|
|
178
|
-
"line. When the work ships, fold it into the brief and mark the plan implemented."
|
|
221
|
+
"`cssclasses: [approved]`, and the APPROVED banner."
|
|
179
222
|
)
|
|
223
|
+
if memory_on:
|
|
224
|
+
result += (
|
|
225
|
+
" If an existing brief is linked, add ✅ on the brief's Open plans line "
|
|
226
|
+
"and, when the work ships, fold it into the brief. Mark the plan implemented "
|
|
227
|
+
"when shipped, even if it is standalone; do not create a brief implicitly."
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
result += (
|
|
231
|
+
" When the work ships, mark the plan implemented. Project memory is off: "
|
|
232
|
+
"update only this plan, not a project brief; do not create or maintain one."
|
|
233
|
+
)
|
|
180
234
|
return result
|
|
181
235
|
|
|
182
236
|
@pcode.hooks.on.before_tool_execute
|
|
183
237
|
async def workers_cannot_exit(ctx, *, call, tool_def, args):
|
|
184
|
-
if tool_def.name == "exit_plan_mode" and
|
|
238
|
+
if tool_def.name == "exit_plan_mode" and is_side(ctx):
|
|
185
239
|
raise ModelRetry("Only the main agent can leave plan mode.")
|
|
186
240
|
return args
|
|
187
241
|
|
|
242
|
+
if not memory_on:
|
|
243
|
+
current = active()
|
|
244
|
+
if current and is_vault(current):
|
|
245
|
+
feedback.observe(path_of(current).resolve())
|
|
246
|
+
|
|
247
|
+
@pcode.hooks.on.before_run
|
|
248
|
+
async def remember_session(ctx):
|
|
249
|
+
nonlocal session
|
|
250
|
+
incoming = base_conversation(getattr(ctx, "conversation_id", None))
|
|
251
|
+
if is_side(ctx) or not incoming or incoming == session:
|
|
252
|
+
return
|
|
253
|
+
if session is not None:
|
|
254
|
+
feedback.seen.clear()
|
|
255
|
+
current = active()
|
|
256
|
+
if current and is_vault(current):
|
|
257
|
+
await asyncio.to_thread(feedback.observe, path_of(current).resolve())
|
|
258
|
+
session = incoming
|
|
259
|
+
|
|
260
|
+
@pcode.hooks.on.after_tool_execute
|
|
261
|
+
async def note_seen(ctx, *, call, tool_def, args, result):
|
|
262
|
+
current = active()
|
|
263
|
+
if (
|
|
264
|
+
current
|
|
265
|
+
and is_vault(current)
|
|
266
|
+
and tool_def.name in NOTE_TOOLS
|
|
267
|
+
and not is_side(ctx)
|
|
268
|
+
and isinstance(args, dict)
|
|
269
|
+
):
|
|
270
|
+
path = Path(str(args.get("path", ""))).expanduser()
|
|
271
|
+
path = (path if path.is_absolute() else pcode.workspace / path).resolve()
|
|
272
|
+
if path == path_of(current).resolve():
|
|
273
|
+
await asyncio.to_thread(feedback.observe, path)
|
|
274
|
+
return result
|
|
275
|
+
|
|
188
276
|
@pcode.hooks.on.before_model_request
|
|
189
277
|
async def remind(ctx, request_context):
|
|
190
278
|
# Once per user turn, persisted in history so the prompt prefix stays
|
|
191
279
|
# cached. A retried turn resends the same request, so skip one that
|
|
192
280
|
# already carries the reminder or is mid-turn (holds tool results).
|
|
193
281
|
current = active()
|
|
194
|
-
if current is None or
|
|
282
|
+
if current is None or is_side(ctx):
|
|
195
283
|
return request_context
|
|
196
284
|
request = run_request(ctx, request_context)
|
|
197
285
|
parts = request.parts if isinstance(request, ModelRequest) else ()
|
|
@@ -201,6 +289,22 @@ def setup(pcode) -> None:
|
|
|
201
289
|
and not any(isinstance(p, ToolReturnPart) for p in parts)
|
|
202
290
|
and not any(str(p.content).startswith("<plan-mode>") for p in prompts)
|
|
203
291
|
):
|
|
204
|
-
extra =
|
|
292
|
+
extra = ""
|
|
293
|
+
if is_vault(current):
|
|
294
|
+
extra = "\n" + plan_guidance(path_of(current).parent)
|
|
295
|
+
if conversation := base_conversation(getattr(ctx, "conversation_id", None)):
|
|
296
|
+
# The Obsidian bridge plugin finds this conversation through it.
|
|
297
|
+
extra += (
|
|
298
|
+
f"\n- Keep `session: {conversation}` in the plan's front matter "
|
|
299
|
+
"(add it, or update it if it names another session)."
|
|
300
|
+
)
|
|
301
|
+
if not memory_on:
|
|
302
|
+
extra += (
|
|
303
|
+
"\n- Project memory is off. Update only this plan, not project briefs, "
|
|
304
|
+
"including on approval and completion."
|
|
305
|
+
)
|
|
306
|
+
changed = await asyncio.to_thread(feedback.edits, path_of(current).resolve())
|
|
307
|
+
if changed:
|
|
308
|
+
extra += "\n" + EDITED + "\n" + "\n\n".join(changed)
|
|
205
309
|
parts.append(UserPromptPart(REMINDER.format(path=relative(current), extra=extra)))
|
|
206
310
|
return request_context
|
|
@@ -200,10 +200,7 @@ def reference_fragment(text: str) -> str | None:
|
|
|
200
200
|
|
|
201
201
|
|
|
202
202
|
class ReferenceLexer(Lexer):
|
|
203
|
-
"""Style file references in the editor so they read as paths, not prose.
|
|
204
|
-
|
|
205
|
-
``extra`` adds further ``(pattern, style)`` tokens, such as paste markers.
|
|
206
|
-
"""
|
|
203
|
+
"""Style file references in the editor so they read as paths, not prose."""
|
|
207
204
|
|
|
208
205
|
def __init__(self, extra: list[tuple[re.Pattern[str], str]] = ()) -> None:
|
|
209
206
|
self.patterns = [(REFERENCE_PATTERN, "class:reference"), *extra]
|