robot_lab-am 0.0.1
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.
- checksums.yaml +7 -0
- data/.envrc +6 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +10 -0
- data/.rubocop.yml +1 -0
- data/Archspec.rb +58 -0
- data/CHANGELOG.md +37 -0
- data/CLAUDE.md +99 -0
- data/LICENSE.txt +21 -0
- data/README.md +118 -0
- data/Rakefile +30 -0
- data/bin/am +13 -0
- data/docs/assets/pipeline.svg +100 -0
- data/docs/cli-reference.md +118 -0
- data/docs/daemon.md +104 -0
- data/docs/getting-started.md +112 -0
- data/docs/how-it-works.md +90 -0
- data/docs/index.md +74 -0
- data/docs/privacy.md +73 -0
- data/lib/robot_lab/am/cli.rb +180 -0
- data/lib/robot_lab/am/collector.rb +48 -0
- data/lib/robot_lab/am/config/defaults.yml +18 -0
- data/lib/robot_lab/am/config.rb +58 -0
- data/lib/robot_lab/am/daemon.rb +118 -0
- data/lib/robot_lab/am/daemon_controller.rb +152 -0
- data/lib/robot_lab/am/event.rb +22 -0
- data/lib/robot_lab/am/event_log.rb +40 -0
- data/lib/robot_lab/am/heartbeat.rb +45 -0
- data/lib/robot_lab/am/inferrer.rb +123 -0
- data/lib/robot_lab/am/intent.rb +9 -0
- data/lib/robot_lab/am/intent_writer.rb +36 -0
- data/lib/robot_lab/am/launchd.rb +108 -0
- data/lib/robot_lab/am/pid_file.rb +49 -0
- data/lib/robot_lab/am/redactor.rb +43 -0
- data/lib/robot_lab/am/version.rb +7 -0
- data/lib/robot_lab/am/watchers/claude_watcher.rb +84 -0
- data/lib/robot_lab/am/watchers/git_watcher.rb +57 -0
- data/lib/robot_lab/am/watchers/terminal_watcher.rb +42 -0
- data/lib/robot_lab/am.rb +40 -0
- data/mkdocs.yml +119 -0
- data/sig/robot_lab/am.rbs +6 -0
- metadata +118 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "ruby_llm"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module Am
|
|
8
|
+
# Summarizes a bounded window of Events into a structured Intent with a
|
|
9
|
+
# one-shot RubyLLM chat — no robots, no robot_lab dependency, so the gem
|
|
10
|
+
# is useful outside the robot_lab-to environment too. `chat:` is
|
|
11
|
+
# injectable so callers (and tests) never have to make a real LLM call
|
|
12
|
+
# to exercise this class.
|
|
13
|
+
#
|
|
14
|
+
# Provider, model, API base, and key come from Am::Config (defaults.yml
|
|
15
|
+
# -> user config file -> RLAM_* env vars -> keyword overrides).
|
|
16
|
+
# The shipped default is a local model served by LM Studio's
|
|
17
|
+
# OpenAI-compatible API (`lms server start`), not a hosted provider —
|
|
18
|
+
# inference over your own activity log shouldn't require sending it,
|
|
19
|
+
# or an API key, anywhere.
|
|
20
|
+
class Inferrer
|
|
21
|
+
# LM Studio ignores the key but RubyLLM's :openai provider requires
|
|
22
|
+
# one to be configured; used only when neither the config nor the
|
|
23
|
+
# provider's conventional env var supplies a real key.
|
|
24
|
+
PLACEHOLDER_API_KEY = "no-key-required"
|
|
25
|
+
|
|
26
|
+
SYSTEM_PROMPT = <<~PROMPT
|
|
27
|
+
You infer what a software developer is currently working on from a
|
|
28
|
+
timestamped activity log: their own git commits and uncommitted
|
|
29
|
+
changes, their own messages in a Claude Code session, and commands
|
|
30
|
+
they ran in a terminal. Respond with ONLY YAML, no other text, in
|
|
31
|
+
exactly this shape:
|
|
32
|
+
|
|
33
|
+
goal: <one or two sentences on what they're currently working toward>
|
|
34
|
+
confidence: <low|medium|high>
|
|
35
|
+
evidence:
|
|
36
|
+
- <short reference to a specific event that supports the goal>
|
|
37
|
+
open_questions:
|
|
38
|
+
- <anything ambiguous or unresolved>
|
|
39
|
+
PROMPT
|
|
40
|
+
|
|
41
|
+
def initialize(chat: nil, config: nil, model: nil, provider: nil)
|
|
42
|
+
@chat = chat
|
|
43
|
+
@config = config
|
|
44
|
+
@model = model
|
|
45
|
+
@provider = provider
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def infer(events, repo:)
|
|
49
|
+
return empty_intent if events.empty?
|
|
50
|
+
|
|
51
|
+
parse(chat.ask(build_prompt(events, repo)).content)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def chat
|
|
57
|
+
@chat ||= build_chat
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def config = @config ||= Am.config
|
|
61
|
+
def model = @model || config.model
|
|
62
|
+
def provider = @provider || config.provider
|
|
63
|
+
|
|
64
|
+
# assume_model_exists: local model names aren't in RubyLLM's registry.
|
|
65
|
+
def build_chat
|
|
66
|
+
llm_context.chat(model: model, provider: provider, assume_model_exists: true)
|
|
67
|
+
.with_instructions(SYSTEM_PROMPT)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A scoped RubyLLM context (a dup of the global config), so embedding
|
|
71
|
+
# robot_lab-am in a larger app never mutates that app's RubyLLM setup.
|
|
72
|
+
# api_base is an OpenAI-compatible-server concern and is only applied
|
|
73
|
+
# to the :openai provider.
|
|
74
|
+
def llm_context
|
|
75
|
+
RubyLLM.context do |llm|
|
|
76
|
+
llm.openai_api_base = config.api_base if provider == :openai
|
|
77
|
+
key_setter = :"#{provider}_api_key="
|
|
78
|
+
llm.public_send(key_setter, api_key) if llm.respond_to?(key_setter)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Cascade: explicit config -> the provider's conventional env var
|
|
83
|
+
# (OPENAI_API_KEY, ANTHROPIC_API_KEY, ...) -> placeholder for keyless
|
|
84
|
+
# local servers.
|
|
85
|
+
def api_key
|
|
86
|
+
config.api_key || ENV.fetch("#{provider.to_s.upcase}_API_KEY") { PLACEHOLDER_API_KEY }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_prompt(events, repo)
|
|
90
|
+
lines = events.sort_by(&:timestamp).map { |e| "[#{e.timestamp}] (#{e.source}/#{e.kind}) #{e.summary}" }
|
|
91
|
+
"Repo: #{repo}\n\nActivity log, oldest to newest:\n#{lines.join("\n")}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse(response)
|
|
95
|
+
data = YAML.safe_load(extract_yaml(response), permitted_classes: [Symbol]) || {}
|
|
96
|
+
build_intent(data, response)
|
|
97
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass
|
|
98
|
+
build_intent({}, response)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_yaml(response)
|
|
102
|
+
response[/```ya?ml\n(.*?)```/m, 1] || response
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# :reek:FeatureEnvy -- this method's whole job is reading the parsed
|
|
106
|
+
# YAML hash into an Intent; that's not a sign it belongs elsewhere.
|
|
107
|
+
def build_intent(data, response)
|
|
108
|
+
Intent.new(
|
|
109
|
+
goal: data["goal"] || response.strip,
|
|
110
|
+
confidence: data["confidence"] || "unknown",
|
|
111
|
+
evidence: Array(data["evidence"]),
|
|
112
|
+
open_questions: Array(data["open_questions"]),
|
|
113
|
+
generated_at: Time.now.utc.iso8601
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def empty_intent
|
|
118
|
+
Intent.new(goal: "No recent activity detected.", confidence: "low", evidence: [],
|
|
119
|
+
open_questions: [], generated_at: Time.now.utc.iso8601)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RobotLab
|
|
7
|
+
module Am
|
|
8
|
+
# Writes the inferred Intent to .robot_lab_am/current_intent.md in the
|
|
9
|
+
# watched repo — front matter + prose, the same shape as robot_lab-to's
|
|
10
|
+
# decision files, under the same .robot_lab_<name>/ naming convention as
|
|
11
|
+
# robot_lab-to's own .robot_lab_to/runs/<run_id>/ run state.
|
|
12
|
+
class IntentWriter
|
|
13
|
+
def initialize(repo:)
|
|
14
|
+
@path = File.join(repo, ".robot_lab_am", "current_intent.md")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def write(intent)
|
|
18
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
19
|
+
File.write(@path, render(intent))
|
|
20
|
+
@path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render(intent)
|
|
26
|
+
front_matter = {
|
|
27
|
+
"confidence" => intent.confidence,
|
|
28
|
+
"generated_at" => intent.generated_at,
|
|
29
|
+
"evidence" => intent.evidence,
|
|
30
|
+
"open_questions" => intent.open_questions
|
|
31
|
+
}
|
|
32
|
+
"#{YAML.dump(front_matter)}---\n\n#{intent.goal}\n"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "rbconfig"
|
|
6
|
+
|
|
7
|
+
module RobotLab
|
|
8
|
+
module Am
|
|
9
|
+
# Generates and installs the per-repo launchd agent plist from
|
|
10
|
+
# ARCHITECTURE.md's Deployment model — process supervision, start on
|
|
11
|
+
# login, restart on crash. One agent per watched repo; the agent just
|
|
12
|
+
# runs `am start --foreground` and lets launchd own the process.
|
|
13
|
+
#
|
|
14
|
+
# Install/uninstall only touch the plist file; loading it into launchd is
|
|
15
|
+
# left to the user (the CLI prints the launchctl commands) so this class
|
|
16
|
+
# has no side effects beyond the file it writes.
|
|
17
|
+
class Launchd
|
|
18
|
+
LABEL_PREFIX = "com.madbomber.robot-lab-am"
|
|
19
|
+
DEFAULT_AGENTS_DIR = File.expand_path("~/Library/LaunchAgents")
|
|
20
|
+
|
|
21
|
+
def initialize(repo:, interval: nil, debounce: nil,
|
|
22
|
+
agents_dir: DEFAULT_AGENTS_DIR, program: nil, config: nil)
|
|
23
|
+
@repo = repo
|
|
24
|
+
@interval = interval
|
|
25
|
+
@debounce = debounce
|
|
26
|
+
@agents_dir = agents_dir
|
|
27
|
+
@program = program
|
|
28
|
+
@config = config
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Repo basename plus a short path hash: readable, and unique even for
|
|
32
|
+
# two checkouts with the same basename.
|
|
33
|
+
def label
|
|
34
|
+
"#{LABEL_PREFIX}.#{File.basename(@repo)}-#{Digest::SHA256.hexdigest(@repo)[0, 8]}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def plist_path
|
|
38
|
+
File.join(@agents_dir, "#{label}.plist")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def install
|
|
42
|
+
FileUtils.mkdir_p(@agents_dir)
|
|
43
|
+
File.write(plist_path, plist_xml)
|
|
44
|
+
plist_path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def uninstall
|
|
48
|
+
raise Error, "no launchd agent installed for #{@repo} (expected #{plist_path})" unless File.exist?(plist_path)
|
|
49
|
+
|
|
50
|
+
FileUtils.rm_f(plist_path)
|
|
51
|
+
plist_path
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def plist_xml
|
|
55
|
+
log = File.join(@repo, ".robot_lab_am", "daemon.log")
|
|
56
|
+
<<~XML
|
|
57
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
58
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
59
|
+
<plist version="1.0">
|
|
60
|
+
<dict>
|
|
61
|
+
<key>Label</key>
|
|
62
|
+
<string>#{xml_escape(label)}</string>
|
|
63
|
+
<key>ProgramArguments</key>
|
|
64
|
+
<array>
|
|
65
|
+
#{program_arguments.map { |arg| " <string>#{xml_escape(arg)}</string>" }.join("\n")}
|
|
66
|
+
</array>
|
|
67
|
+
<key>WorkingDirectory</key>
|
|
68
|
+
<string>#{xml_escape(@repo)}</string>
|
|
69
|
+
<key>RunAtLoad</key>
|
|
70
|
+
<true/>
|
|
71
|
+
<key>KeepAlive</key>
|
|
72
|
+
<true/>
|
|
73
|
+
<key>StandardOutPath</key>
|
|
74
|
+
<string>#{xml_escape(log)}</string>
|
|
75
|
+
<key>StandardErrorPath</key>
|
|
76
|
+
<string>#{xml_escape(log)}</string>
|
|
77
|
+
</dict>
|
|
78
|
+
</plist>
|
|
79
|
+
XML
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# launchd starts agents with a minimal PATH, so invoke the script with
|
|
85
|
+
# the same ruby this process is running under rather than trusting the
|
|
86
|
+
# shebang's `env ruby` to resolve.
|
|
87
|
+
def config = @config ||= Am.config
|
|
88
|
+
def interval = @interval || config.interval
|
|
89
|
+
def debounce = @debounce || config.debounce
|
|
90
|
+
|
|
91
|
+
def program
|
|
92
|
+
@program ||= File.expand_path($PROGRAM_NAME)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# interval/debounce are baked into the plist so the launchd-run daemon
|
|
96
|
+
# behaves the same as the `am install` invocation that created it,
|
|
97
|
+
# regardless of later config-file edits.
|
|
98
|
+
def program_arguments
|
|
99
|
+
[RbConfig.ruby, program, "start", "--foreground", "--repo", @repo,
|
|
100
|
+
"--interval", interval.to_s, "--debounce", debounce.to_s]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def xml_escape(text)
|
|
104
|
+
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Am
|
|
7
|
+
# The daemon's pid on disk (.robot_lab_am/daemon.pid). A pid file alone
|
|
8
|
+
# can lie after a crash, which is why the daemon also writes a Heartbeat —
|
|
9
|
+
# but liveness here is still checked against the real process table
|
|
10
|
+
# (signal 0), not just the file's existence.
|
|
11
|
+
class PidFile
|
|
12
|
+
def initialize(path)
|
|
13
|
+
@path = path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def write(pid = Process.pid)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
18
|
+
File.write(@path, pid.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def read
|
|
22
|
+
return nil unless File.exist?(@path)
|
|
23
|
+
|
|
24
|
+
pid = File.read(@path).to_i
|
|
25
|
+
pid.positive? ? pid : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def delete
|
|
29
|
+
FileUtils.rm_f(@path)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def exist?
|
|
33
|
+
File.exist?(@path)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def alive?
|
|
37
|
+
pid = read
|
|
38
|
+
return false unless pid
|
|
39
|
+
|
|
40
|
+
Process.kill(0, pid)
|
|
41
|
+
true
|
|
42
|
+
rescue Errno::ESRCH
|
|
43
|
+
false
|
|
44
|
+
rescue Errno::EPERM
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Am
|
|
5
|
+
# Masks obvious secrets in event summaries before they reach the event
|
|
6
|
+
# store or an LLM, per ARCHITECTURE.md's Privacy & redaction section.
|
|
7
|
+
# Terminal commands and git WIP output are the risky sources (API keys in
|
|
8
|
+
# `export` statements, credentials in fixtures). Deliberately pattern-based
|
|
9
|
+
# and conservative — it catches the common shapes, not every possible leak.
|
|
10
|
+
module Redactor
|
|
11
|
+
MASK = "[REDACTED]"
|
|
12
|
+
|
|
13
|
+
# `NAME=value` / `name: value` where the name smells like a credential.
|
|
14
|
+
KEY_VALUE = /(\b[\w-]*(?:key|token|secret|password|passwd|credential)s?\b["']?\s*(?:=>|[=:])\s*["']?)([^\s"']+)/i
|
|
15
|
+
|
|
16
|
+
# `Authorization: Bearer <token>` and friends.
|
|
17
|
+
BEARER = /\b(bearer\s+)([^\s"']+)/i
|
|
18
|
+
|
|
19
|
+
# Well-known token formats that are secrets wherever they appear.
|
|
20
|
+
KNOWN_TOKEN = /
|
|
21
|
+
\b(?:
|
|
22
|
+
sk-[A-Za-z0-9_-]{16,} # OpenAI-style
|
|
23
|
+
| AKIA[0-9A-Z]{16} # AWS access key id
|
|
24
|
+
| gh[pousr]_[A-Za-z0-9]{20,} # GitHub tokens
|
|
25
|
+
| github_pat_[A-Za-z0-9_]{20,} # GitHub fine-grained PAT
|
|
26
|
+
| xox[abprs]-[A-Za-z0-9-]{10,} # Slack tokens
|
|
27
|
+
)\b
|
|
28
|
+
/x
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def redact_event(event)
|
|
33
|
+
event.with(summary: redact(event.summary))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def redact(text)
|
|
37
|
+
text.gsub(KEY_VALUE, "\\1#{MASK}")
|
|
38
|
+
.gsub(BEARER, "\\1#{MASK}")
|
|
39
|
+
.gsub(KNOWN_TOKEN, MASK)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Am
|
|
7
|
+
module Watchers
|
|
8
|
+
# The human's own plain-language messages from their most recent
|
|
9
|
+
# Claude Code session transcript for this repo. Richest, lowest-effort
|
|
10
|
+
# source per ARCHITECTURE.md — already on disk, no new instrumentation.
|
|
11
|
+
class ClaudeWatcher
|
|
12
|
+
PROJECTS_DIR = File.expand_path("~/.claude/projects")
|
|
13
|
+
|
|
14
|
+
def initialize(repo:, limit: 20, projects_dir: PROJECTS_DIR)
|
|
15
|
+
@repo = File.expand_path(repo)
|
|
16
|
+
@limit = limit
|
|
17
|
+
@projects_dir = projects_dir
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def events
|
|
21
|
+
file = latest_transcript
|
|
22
|
+
return [] unless file
|
|
23
|
+
|
|
24
|
+
human_messages(file)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def latest_transcript
|
|
30
|
+
dir = project_dir
|
|
31
|
+
return nil unless dir
|
|
32
|
+
|
|
33
|
+
Dir.glob(File.join(dir, "*.jsonl")).max_by { |f| File.mtime(f) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Claude Code slugifies the cwd into a project directory name by
|
|
37
|
+
# replacing every non-alphanumeric character with "-". Older
|
|
38
|
+
# sessions kept underscores; fall back to a normalized match so
|
|
39
|
+
# both schemes resolve to the same repo.
|
|
40
|
+
def project_dir
|
|
41
|
+
slug = @repo.gsub(/[^a-zA-Z0-9]/, "-")
|
|
42
|
+
exact = File.join(@projects_dir, slug)
|
|
43
|
+
return exact if Dir.exist?(exact)
|
|
44
|
+
|
|
45
|
+
normalized = slug.tr("_", "-")
|
|
46
|
+
Dir.glob(File.join(@projects_dir, "*")).find do |dir|
|
|
47
|
+
File.directory?(dir) && File.basename(dir).tr("_", "-") == normalized
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def human_messages(file)
|
|
52
|
+
File.readlines(file).last(500).filter_map { |line| human_message_event(line) }.last(@limit)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# :reek:FeatureEnvy -- parsing one transcript line into an Event is
|
|
56
|
+
# inherently reading `record`/`text` more than `self`.
|
|
57
|
+
def human_message_event(line)
|
|
58
|
+
record = JSON.parse(line)
|
|
59
|
+
return nil unless record["type"] == "user"
|
|
60
|
+
|
|
61
|
+
text = extract_text(record.dig("message", "content"))
|
|
62
|
+
return nil if text.nil? || text.strip.empty?
|
|
63
|
+
|
|
64
|
+
Event.new(timestamp: record["timestamp"], repo: @repo, source: "claude_code",
|
|
65
|
+
kind: "user_message", summary: text.strip[0, 500])
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Only plain text blocks count as "the human said something" — this
|
|
71
|
+
# skips synthetic user turns that are actually tool_result payloads.
|
|
72
|
+
def extract_text(content)
|
|
73
|
+
return content if content.is_a?(String)
|
|
74
|
+
return nil unless content.is_a?(Array)
|
|
75
|
+
|
|
76
|
+
blocks = content.select { |block| block["type"] == "text" }
|
|
77
|
+
return nil if blocks.empty?
|
|
78
|
+
|
|
79
|
+
blocks.map { |block| block["text"] }.join("\n")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Am
|
|
7
|
+
module Watchers
|
|
8
|
+
# Recent commits plus current uncommitted state, for one repo.
|
|
9
|
+
# All subprocess calls use explicit argv arrays (no shell interpolation).
|
|
10
|
+
class GitWatcher
|
|
11
|
+
GIT_ENV = { "GIT_TERMINAL_PROMPT" => "0" }.freeze
|
|
12
|
+
LOG_FORMAT = "%H%x09%aI%x09%s"
|
|
13
|
+
|
|
14
|
+
def initialize(repo:, limit: 20)
|
|
15
|
+
@repo = repo
|
|
16
|
+
@limit = limit
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def events
|
|
20
|
+
commit_events + wip_events
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def commit_events
|
|
26
|
+
out, _err, status = Open3.capture3(
|
|
27
|
+
GIT_ENV, "git", "log", "-n", @limit.to_s, "--format=#{LOG_FORMAT}", chdir: @repo
|
|
28
|
+
)
|
|
29
|
+
return [] unless status.success?
|
|
30
|
+
|
|
31
|
+
out.each_line.filter_map { |line| commit_event(line) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def commit_event(line)
|
|
35
|
+
sha, iso_time, subject = line.chomp.split("\t", 3)
|
|
36
|
+
return nil unless sha && iso_time
|
|
37
|
+
|
|
38
|
+
Event.new(timestamp: iso_time, repo: @repo, source: "git", kind: "commit",
|
|
39
|
+
summary: "#{sha[0, 7]} #{subject}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def wip_events
|
|
43
|
+
out, _err, status = Open3.capture3(GIT_ENV, "git", "status", "--short", chdir: @repo)
|
|
44
|
+
return [] unless status.success?
|
|
45
|
+
|
|
46
|
+
# This gem's own state dir is not the human's work — reporting it
|
|
47
|
+
# would make the daemon observe (and infer over) its own footprint.
|
|
48
|
+
lines = out.lines.map(&:chomp).reject { |line| line.empty? || line.include?(".robot_lab_am") }
|
|
49
|
+
return [] if lines.empty?
|
|
50
|
+
|
|
51
|
+
[Event.new(timestamp: Time.now.utc.iso8601, repo: @repo, source: "git", kind: "wip",
|
|
52
|
+
summary: "Uncommitted changes:\n#{lines.join("\n")}")]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RobotLab
|
|
4
|
+
module Am
|
|
5
|
+
module Watchers
|
|
6
|
+
# Commands run in the repo, read from the global preexec log written
|
|
7
|
+
# by ~/.bashrc__activity_monitor. That log spans every shell on the
|
|
8
|
+
# machine (see ARCHITECTURE.md's Privacy & redaction section) — the
|
|
9
|
+
# opt-in boundary is enforced here, at read time, by filtering to
|
|
10
|
+
# lines whose cwd falls under this one watched repo root.
|
|
11
|
+
class TerminalWatcher
|
|
12
|
+
DEFAULT_LOG_PATH = File.expand_path("~/.activity_monitor/terminal_activity.log")
|
|
13
|
+
|
|
14
|
+
def initialize(repo:, log_path: DEFAULT_LOG_PATH, limit: 50)
|
|
15
|
+
@repo = File.expand_path(repo)
|
|
16
|
+
@log_path = log_path
|
|
17
|
+
@limit = limit
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def events
|
|
21
|
+
return [] unless File.exist?(@log_path)
|
|
22
|
+
|
|
23
|
+
File.readlines(@log_path).filter_map { |line| command_event(line) }.last(@limit)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def command_event(line)
|
|
29
|
+
epoch, cwd, command = line.chomp.split("\t", 3)
|
|
30
|
+
return nil unless epoch && cwd && command && in_scope?(cwd)
|
|
31
|
+
|
|
32
|
+
Event.new(timestamp: Time.at(epoch.to_f).utc.iso8601, repo: @repo, source: "terminal",
|
|
33
|
+
kind: "command", summary: command)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def in_scope?(cwd)
|
|
37
|
+
cwd == @repo || cwd.start_with?("#{@repo}/")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/robot_lab/am.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "am/version"
|
|
4
|
+
require_relative "am/config"
|
|
5
|
+
require_relative "am/event"
|
|
6
|
+
require_relative "am/event_log"
|
|
7
|
+
require_relative "am/redactor"
|
|
8
|
+
require_relative "am/intent"
|
|
9
|
+
require_relative "am/inferrer"
|
|
10
|
+
require_relative "am/intent_writer"
|
|
11
|
+
require_relative "am/watchers/git_watcher"
|
|
12
|
+
require_relative "am/watchers/claude_watcher"
|
|
13
|
+
require_relative "am/watchers/terminal_watcher"
|
|
14
|
+
require_relative "am/collector"
|
|
15
|
+
require_relative "am/pid_file"
|
|
16
|
+
require_relative "am/heartbeat"
|
|
17
|
+
require_relative "am/daemon"
|
|
18
|
+
require_relative "am/daemon_controller"
|
|
19
|
+
require_relative "am/launchd"
|
|
20
|
+
require_relative "am/cli"
|
|
21
|
+
|
|
22
|
+
module RobotLab
|
|
23
|
+
module Am
|
|
24
|
+
class Error < StandardError; end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Process-wide Config (defaults.yml -> user config file ->
|
|
28
|
+
# RLAM_* env vars). Components use it for any setting not
|
|
29
|
+
# passed to them explicitly.
|
|
30
|
+
def config
|
|
31
|
+
@config ||= Config.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Test hook: drop the memoized config so changed env vars are re-read.
|
|
35
|
+
def reset_config!
|
|
36
|
+
@config = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|